Root rename detection (#20313)

Closes #5349

Release Notes:

- Fixed Zed when the directory that you opened is renamed.
This commit is contained in:
Conrad Irwin 2024-11-06 20:36:59 -07:00 committed by GitHub
parent 216ea4ddc4
commit e645aa9d20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 196 additions and 4 deletions

View file

@ -10,7 +10,11 @@ use git::GitHostingProviderRegistry;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use ashpd::desktop::trash; use ashpd::desktop::trash;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use std::{fs::File, os::fd::AsFd}; use std::fs::File;
#[cfg(unix)]
use std::os::fd::AsFd;
#[cfg(unix)]
use std::os::fd::AsRawFd;
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
@ -51,14 +55,14 @@ pub trait Watcher: Send + Sync {
fn remove(&self, path: &Path) -> Result<()>; fn remove(&self, path: &Path) -> Result<()>;
} }
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum PathEventKind { pub enum PathEventKind {
Removed, Removed,
Created, Created,
Changed, Changed,
} }
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct PathEvent { pub struct PathEvent {
pub path: PathBuf, pub path: PathBuf,
pub kind: Option<PathEventKind>, pub kind: Option<PathEventKind>,
@ -95,6 +99,7 @@ pub trait Fs: Send + Sync {
async fn trash_file(&self, path: &Path, options: RemoveOptions) -> Result<()> { async fn trash_file(&self, path: &Path, options: RemoveOptions) -> Result<()> {
self.remove_file(path, options).await self.remove_file(path, options).await
} }
async fn open_handle(&self, path: &Path) -> Result<Arc<dyn FileHandle>>;
async fn open_sync(&self, path: &Path) -> Result<Box<dyn io::Read>>; async fn open_sync(&self, path: &Path) -> Result<Box<dyn io::Read>>;
async fn load(&self, path: &Path) -> Result<String> { async fn load(&self, path: &Path) -> Result<String> {
Ok(String::from_utf8(self.load_bytes(path).await?)?) Ok(String::from_utf8(self.load_bytes(path).await?)?)
@ -187,6 +192,52 @@ pub struct RealFs {
git_binary_path: Option<PathBuf>, git_binary_path: Option<PathBuf>,
} }
pub trait FileHandle: Send + Sync + std::fmt::Debug {
fn current_path(&self, fs: &Arc<dyn Fs>) -> Result<PathBuf>;
}
impl FileHandle for std::fs::File {
#[cfg(target_os = "macos")]
fn current_path(&self, _: &Arc<dyn Fs>) -> Result<PathBuf> {
use std::{
ffi::{CStr, OsStr},
os::unix::ffi::OsStrExt,
};
let fd = self.as_fd();
let mut path_buf: [libc::c_char; libc::PATH_MAX as usize] = [0; libc::PATH_MAX as usize];
let result = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETPATH, path_buf.as_mut_ptr()) };
if result == -1 {
anyhow::bail!("fcntl returned -1".to_string());
}
let c_str = unsafe { CStr::from_ptr(path_buf.as_ptr()) };
let path = PathBuf::from(OsStr::from_bytes(c_str.to_bytes()));
Ok(path)
}
#[cfg(target_os = "linux")]
fn current_path(&self, _: &Arc<dyn Fs>) -> Result<PathBuf> {
let fd = self.as_fd();
let fd_path = format!("/proc/self/fd/{}", fd.as_raw_fd());
let new_path = std::fs::read_link(fd_path)?;
if new_path
.file_name()
.is_some_and(|f| f.to_string_lossy().ends_with(" (deleted)"))
{
anyhow::bail!("file was deleted")
};
Ok(new_path)
}
#[cfg(target_os = "windows")]
fn current_path(&self, _: &Arc<dyn Fs>) -> Result<PathBuf> {
anyhow::bail!("unimplemented")
}
}
pub struct RealWatcher {} pub struct RealWatcher {}
impl RealFs { impl RealFs {
@ -400,6 +451,10 @@ impl Fs for RealFs {
Ok(Box::new(std::fs::File::open(path)?)) Ok(Box::new(std::fs::File::open(path)?))
} }
async fn open_handle(&self, path: &Path) -> Result<Arc<dyn FileHandle>> {
Ok(Arc::new(std::fs::File::open(path)?))
}
async fn load(&self, path: &Path) -> Result<String> { async fn load(&self, path: &Path) -> Result<String> {
let path = path.to_path_buf(); let path = path.to_path_buf();
let text = smol::unblock(|| std::fs::read_to_string(path)).await?; let text = smol::unblock(|| std::fs::read_to_string(path)).await?;
@ -755,6 +810,7 @@ struct FakeFsState {
buffered_events: Vec<PathEvent>, buffered_events: Vec<PathEvent>,
metadata_call_count: usize, metadata_call_count: usize,
read_dir_call_count: usize, read_dir_call_count: usize,
moves: std::collections::HashMap<u64, PathBuf>,
} }
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
@ -926,6 +982,7 @@ impl FakeFs {
events_paused: false, events_paused: false,
read_dir_call_count: 0, read_dir_call_count: 0,
metadata_call_count: 0, metadata_call_count: 0,
moves: Default::default(),
}), }),
}); });
@ -1362,6 +1419,27 @@ impl Watcher for FakeWatcher {
} }
} }
#[cfg(any(test, feature = "test-support"))]
#[derive(Debug)]
struct FakeHandle {
inode: u64,
}
#[cfg(any(test, feature = "test-support"))]
impl FileHandle for FakeHandle {
fn current_path(&self, fs: &Arc<dyn Fs>) -> Result<PathBuf> {
let state = fs.as_fake().state.lock();
let Some(target) = state.moves.get(&self.inode) else {
anyhow::bail!("fake fd not moved")
};
if state.try_read_path(&target, false).is_some() {
return Ok(target.clone());
}
anyhow::bail!("fake fd target not found")
}
}
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
#[async_trait::async_trait] #[async_trait::async_trait]
impl Fs for FakeFs { impl Fs for FakeFs {
@ -1500,6 +1578,14 @@ impl Fs for FakeFs {
} }
})?; })?;
let inode = match *moved_entry.lock() {
FakeFsEntry::File { inode, .. } => inode,
FakeFsEntry::Dir { inode, .. } => inode,
_ => 0,
};
state.moves.insert(inode, new_path.clone());
state.write_path(&new_path, |e| { state.write_path(&new_path, |e| {
match e { match e {
btree_map::Entry::Occupied(mut e) => { btree_map::Entry::Occupied(mut e) => {
@ -1644,6 +1730,19 @@ impl Fs for FakeFs {
Ok(Box::new(io::Cursor::new(bytes))) Ok(Box::new(io::Cursor::new(bytes)))
} }
async fn open_handle(&self, path: &Path) -> Result<Arc<dyn FileHandle>> {
self.simulate_random_delay().await;
let state = self.state.lock();
let entry = state.read_path(&path)?;
let entry = entry.lock();
let inode = match *entry {
FakeFsEntry::File { inode, .. } => inode,
FakeFsEntry::Dir { inode, .. } => inode,
_ => unreachable!(),
};
Ok(Arc::new(FakeHandle { inode }))
}
async fn load(&self, path: &Path) -> Result<String> { async fn load(&self, path: &Path) -> Result<String> {
let content = self.load_internal(path).await?; let content = self.load_internal(path).await?;
Ok(String::from_utf8(content.clone())?) Ok(String::from_utf8(content.clone())?)

View file

@ -1086,6 +1086,45 @@ async fn test_reconnect(cx: &mut TestAppContext, server_cx: &mut TestAppContext)
); );
} }
#[gpui::test]
async fn test_remote_root_rename(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
let fs = FakeFs::new(server_cx.executor());
fs.insert_tree(
"/code",
json!({
"project1": {
".git": {},
"README.md": "# project 1",
},
}),
)
.await;
let (project, _) = init_test(&fs, cx, server_cx).await;
let (worktree, _) = project
.update(cx, |project, cx| {
project.find_or_create_worktree("/code/project1", true, cx)
})
.await
.unwrap();
cx.run_until_parked();
fs.rename(
&PathBuf::from("/code/project1"),
&PathBuf::from("/code/project2"),
Default::default(),
)
.await
.unwrap();
cx.run_until_parked();
worktree.update(cx, |worktree, _| {
assert_eq!(worktree.root_name(), "project2")
})
}
#[gpui::test] #[gpui::test]
async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
let fs = FakeFs::new(server_cx.executor()); let fs = FakeFs::new(server_cx.executor());

View file

@ -285,6 +285,9 @@ pub struct LocalSnapshot {
/// All of the git repositories in the worktree, indexed by the project entry /// All of the git repositories in the worktree, indexed by the project entry
/// id of their parent directory. /// id of their parent directory.
git_repositories: TreeMap<ProjectEntryId, LocalRepositoryEntry>, git_repositories: TreeMap<ProjectEntryId, LocalRepositoryEntry>,
/// The file handle of the root dir
/// (so we can find it after it's been moved)
root_file_handle: Option<Arc<dyn fs::FileHandle>>,
} }
struct BackgroundScannerState { struct BackgroundScannerState {
@ -341,6 +344,9 @@ enum ScanState {
barrier: SmallVec<[barrier::Sender; 1]>, barrier: SmallVec<[barrier::Sender; 1]>,
scanning: bool, scanning: bool,
}, },
RootUpdated {
new_path: Option<Arc<Path>>,
},
} }
struct UpdateObservationState { struct UpdateObservationState {
@ -382,6 +388,8 @@ impl Worktree {
true true
}); });
let root_file_handle = fs.open_handle(&abs_path).await.log_err();
cx.new_model(move |cx: &mut ModelContext<Worktree>| { cx.new_model(move |cx: &mut ModelContext<Worktree>| {
let mut snapshot = LocalSnapshot { let mut snapshot = LocalSnapshot {
ignores_by_parent_abs_path: Default::default(), ignores_by_parent_abs_path: Default::default(),
@ -393,6 +401,7 @@ impl Worktree {
.map_or(String::new(), |f| f.to_string_lossy().to_string()), .map_or(String::new(), |f| f.to_string_lossy().to_string()),
abs_path, abs_path,
), ),
root_file_handle,
}; };
if let Some(metadata) = metadata { if let Some(metadata) = metadata {
@ -1076,6 +1085,17 @@ impl LocalWorktree {
this.set_snapshot(snapshot, changes, cx); this.set_snapshot(snapshot, changes, cx);
drop(barrier); drop(barrier);
} }
ScanState::RootUpdated { new_path } => {
if let Some(new_path) = new_path {
this.snapshot.git_repositories = Default::default();
this.snapshot.ignores_by_parent_abs_path = Default::default();
let root_name = new_path
.file_name()
.map_or(String::new(), |f| f.to_string_lossy().to_string());
this.snapshot.update_abs_path(new_path, root_name);
}
this.restart_background_scanners(cx);
}
} }
cx.notify(); cx.notify();
}) })
@ -2073,12 +2093,24 @@ impl Snapshot {
.and_then(|entry| entry.git_status) .and_then(|entry| entry.git_status)
} }
fn update_abs_path(&mut self, abs_path: Arc<Path>, root_name: String) {
self.abs_path = abs_path;
if root_name != self.root_name {
self.root_char_bag = root_name.chars().map(|c| c.to_ascii_lowercase()).collect();
self.root_name = root_name;
}
}
pub(crate) fn apply_remote_update(&mut self, mut update: proto::UpdateWorktree) -> Result<()> { pub(crate) fn apply_remote_update(&mut self, mut update: proto::UpdateWorktree) -> Result<()> {
log::trace!( log::trace!(
"applying remote worktree update. {} entries updated, {} removed", "applying remote worktree update. {} entries updated, {} removed",
update.updated_entries.len(), update.updated_entries.len(),
update.removed_entries.len() update.removed_entries.len()
); );
self.update_abs_path(
Arc::from(PathBuf::from(update.abs_path).as_path()),
update.root_name,
);
let mut entries_by_path_edits = Vec::new(); let mut entries_by_path_edits = Vec::new();
let mut entries_by_id_edits = Vec::new(); let mut entries_by_id_edits = Vec::new();
@ -3732,7 +3764,29 @@ impl BackgroundScanner {
let root_canonical_path = match self.fs.canonicalize(&root_path).await { let root_canonical_path = match self.fs.canonicalize(&root_path).await {
Ok(path) => path, Ok(path) => path,
Err(err) => { Err(err) => {
log::error!("failed to canonicalize root path: {}", err); let new_path = self
.state
.lock()
.snapshot
.root_file_handle
.clone()
.and_then(|handle| handle.current_path(&self.fs).log_err())
.filter(|new_path| **new_path != *root_path);
if let Some(new_path) = new_path.as_ref() {
log::info!(
"root renamed from {} to {}",
root_path.display(),
new_path.display()
)
} else {
log::warn!("root path could not be canonicalized: {}", err);
}
self.status_updates_tx
.unbounded_send(ScanState::RootUpdated {
new_path: new_path.map(|p| p.into()),
})
.ok();
return; return;
} }
}; };