From 03c5d379724e4b2e74418be6c7ec706eb7d44033 Mon Sep 17 00:00:00 2001 From: Daniel Strobusch <1847260+dastrobu@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:12:32 +0200 Subject: [PATCH] worktree: Close single-file worktrees when file is deleted (#49366) 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 --- .../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(-) diff --git a/crates/edit_prediction/src/license_detection.rs b/crates/edit_prediction/src/license_detection.rs index 2b44825c4ceef1a317034966aa1a0b6a7a0f54c2..6f701d13a9d4d915bbfbc2442ea5643afac30ef4 100644 --- a/crates/edit_prediction/src/license_detection.rs +++ b/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(); diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 10fb447a6f6c7867212a4622d084deb4fcea91a2..286d3a85f86173bff5d17d8d7c86d26464a04714 100644 --- a/crates/project/src/lsp_store.rs +++ b/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() } diff --git a/crates/project/src/manifest_tree.rs b/crates/project/src/manifest_tree.rs index 82dd1bc0d3fdd0149ced5ce3f2cf9ae480c9f2b7..1ae5b0e809f3803c3f8858afb065637ba0a0f256 100644 --- a/crates/project/src/manifest_tree.rs +++ b/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 => {} } }), }) diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index 4d464182fa670c6efc7ea2644abd68ef0dcda90a..92f7db453a81c6224455002b7811f2e6945f2a82 100644 --- a/crates/project/src/worktree_store.rs +++ b/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(); diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 1ad3e7ecaa694326aa479b5b69ccd3206fbf1e8d..6665dfa532be31eedba6e522cafd06945e3b33e4 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -355,6 +355,7 @@ enum ScanState { RootUpdated { new_path: Arc, }, + 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 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, 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; } diff --git a/crates/worktree/tests/integration/main.rs b/crates/worktree/tests/integration/main.rs index fb8ec444dd324e935aad873bf201d4f0b8ae2019..cd7dd1c9056a7d501bec2bcd7b07d596f689a908 100644 --- a/crates/worktree/tests/integration/main.rs +++ b/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" + ); +}