Detailed changes
@@ -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();
@@ -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()
}
@@ -59,6 +59,7 @@ impl WorktreeRoots {
let path = TriePath::from(entry.path.as_ref());
this.roots.remove(&path);
}
+ WorktreeEvent::Deleted => {}
}
}),
})
@@ -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();
@@ -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;
}
@@ -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"
+ );
+}