pub mod fs_watcher; use parking_lot::Mutex; use std::ffi::OsString; use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; use std::time::Instant; use util::maybe; use anyhow::{Context as _, Result, anyhow}; use futures::stream::iter; use gpui::App; use gpui::BackgroundExecutor; use gpui::Global; use gpui::ReadGlobal as _; use gpui::SharedString; #[cfg(unix)] use std::ffi::CString; use util::command::new_command; #[cfg(unix)] use std::os::fd::{AsFd, AsRawFd}; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt}; #[cfg(any(target_os = "macos", target_os = "freebsd"))] use std::mem::MaybeUninit; use async_tar::Archive; use futures::{AsyncRead, Stream, StreamExt, future::BoxFuture}; use git::repository::{GitRepository, RealGitRepository}; use is_executable::IsExecutable; use rope::Rope; use serde::{Deserialize, Serialize}; use smol::io::AsyncWriteExt; #[cfg(feature = "test-support")] use std::path::Component; use std::{ io::{self, Write}, path::{Path, PathBuf}, pin::Pin, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; use tempfile::TempDir; use text::LineEnding; #[cfg(feature = "test-support")] mod fake_git_repo; #[cfg(feature = "test-support")] use collections::{BTreeMap, btree_map}; #[cfg(feature = "test-support")] use fake_git_repo::{FakeCommitDataEntry, FakeGitRepositoryState}; #[cfg(feature = "test-support")] use git::{ repository::{CommitData, InitialGraphCommitData, RepoPath, Worktree, repo_path}, status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus}, }; #[cfg(feature = "test-support")] use util::normalize_path; #[cfg(feature = "test-support")] use smol::io::AsyncReadExt; #[cfg(feature = "test-support")] use std::ffi::OsStr; pub trait Watcher: Send + Sync { fn add(&self, path: &Path) -> Result<()>; fn remove(&self, path: &Path) -> Result<()>; } /// Detect whether a path requires polling instead of native file watching. /// /// Returns `true` for filesystem types where inotify/FSEvents/ReadDirectoryChanges /// silently fail to deliver events: 9P (WSL drvfs), NFS, CIFS/SMB, FUSE (sshfs), etc. /// /// Can be overridden with the `ZED_FILE_WATCHER_MODE` environment variable: /// - `native` — always use native OS watcher /// - `poll` — always use polling /// - `auto` (default) — auto-detect based on filesystem type pub fn requires_poll_watcher(path: &Path) -> bool { match std::env::var("ZED_FILE_WATCHER_MODE") .as_deref() .unwrap_or("auto") { "native" => return false, "poll" => return true, _ => {} } #[cfg(target_os = "linux")] { let path = effective_watch_path(path); return detect_requires_poll_watcher_linux(&path); } #[cfg(not(target_os = "linux"))] { let _ = path; false } } pub fn effective_watch_path(path: &Path) -> PathBuf { if path.exists() { return path.to_path_buf(); } for ancestor in path.ancestors() { if ancestor.exists() { return ancestor.to_path_buf(); } } path.to_path_buf() } #[cfg(target_os = "linux")] fn detect_requires_poll_watcher_linux(path: &Path) -> bool { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; // Check filesystem type via statfs let c_path = match CString::new(path.as_os_str().as_bytes()) { Ok(p) => p, Err(_) => return false, }; let mut stat: libc::statfs = unsafe { std::mem::zeroed() }; if unsafe { libc::statfs(c_path.as_ptr(), &mut stat) } != 0 { return false; } // Filesystem magic numbers where inotify does not deliver events. // These are defined in linux/magic.h and statfs(2). const V9FS_MAGIC: u64 = 0x0102_1997; // Plan 9 / WSL2 interop (drvfs) const NFS_SUPER_MAGIC: u64 = 0x0000_6969; const CIFS_MAGIC: u64 = 0xFF53_4D42; // CIFS/SMB const SMB_SUPER_MAGIC: u64 = 0x0000_517B; const SMB2_MAGIC: u64 = 0xFE53_4D42; const FUSE_SUPER_MAGIC: u64 = 0x6573_5546; // FUSE (includes sshfs) let fs_type = (stat.f_type as u64) & 0xFFFF_FFFF; if fs_type == V9FS_MAGIC || fs_type == NFS_SUPER_MAGIC || fs_type == CIFS_MAGIC || fs_type == SMB_SUPER_MAGIC || fs_type == SMB2_MAGIC || fs_type == FUSE_SUPER_MAGIC { log::info!( "Detected network/virtual filesystem (type 0x{:x}) at {}, using poll watcher", fs_type, path.display() ); return true; } // Also check for WSL + /mnt// pattern as a fallback // in case statfs returns an unexpected type for drvfs if is_wsl_drvfs_path(path) { log::info!( "Detected WSL drvfs mount at {}, using poll watcher", path.display() ); return true; } false } #[cfg(target_os = "linux")] fn is_wsl_drvfs_path(path: &Path) -> bool { // Only relevant inside WSL if std::env::var_os("WSL_DISTRO_NAME").is_none() { if let Ok(version) = std::fs::read_to_string("/proc/version") { let v = version.to_lowercase(); if !v.contains("microsoft") && !v.contains("wsl") { return false; } } else { return false; } } // Windows drives are mounted at /mnt/c, /mnt/d, etc. let path_str = match path.to_str() { Some(s) => s, None => return false, }; if !path_str.starts_with("/mnt/") || path_str.len() < 6 { return false; } let after_mnt = &path_str[5..]; after_mnt.starts_with(|c: char| c.is_ascii_alphabetic()) && (after_mnt.len() == 1 || after_mnt.as_bytes()[1] == b'/') } #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] pub enum PathEventKind { Removed, Created, Changed, Rescan, } #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] pub struct PathEvent { pub path: PathBuf, pub kind: Option, } impl From for PathBuf { fn from(event: PathEvent) -> Self { event.path } } #[async_trait::async_trait] pub trait Fs: Send + Sync { async fn create_dir(&self, path: &Path) -> Result<()>; async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()>; async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()>; async fn create_file_with( &self, path: &Path, content: Pin<&mut (dyn AsyncRead + Send)>, ) -> Result<()>; async fn extract_tar_file( &self, path: &Path, content: Archive>, ) -> Result<()>; async fn copy_file(&self, source: &Path, target: &Path, options: CopyOptions) -> Result<()>; async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()>; /// Removes a directory from the filesystem. /// There is no expectation that the directory will be preserved in the /// system trash. async fn remove_dir(&self, path: &Path, options: RemoveOptions) -> Result<()>; /// Moves a file or directory to the system trash. /// Returns a [`TrashedEntry`] that can be used to keep track of the /// location of the trashed item in the system's trash. async fn trash(&self, path: &Path, options: RemoveOptions) -> Result; /// Removes a file from the filesystem. /// There is no expectation that the file will be preserved in the system /// trash. async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()>; async fn open_handle(&self, path: &Path) -> Result>; async fn open_sync(&self, path: &Path) -> Result>; async fn load(&self, path: &Path) -> Result { Ok(String::from_utf8(self.load_bytes(path).await?)?) } async fn load_bytes(&self, path: &Path) -> Result>; async fn atomic_write(&self, path: PathBuf, text: String) -> Result<()>; async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()>; async fn write(&self, path: &Path, content: &[u8]) -> Result<()>; async fn canonicalize(&self, path: &Path) -> Result; async fn is_file(&self, path: &Path) -> bool; async fn is_dir(&self, path: &Path) -> bool; async fn metadata(&self, path: &Path) -> Result>; async fn read_link(&self, path: &Path) -> Result; async fn read_dir( &self, path: &Path, ) -> Result>>>>; async fn watch( &self, path: &Path, latency: Duration, ) -> ( Pin>>>, Arc, ); fn open_repo( &self, abs_dot_git: &Path, system_git_binary_path: Option<&Path>, ) -> Result>; async fn git_init(&self, abs_work_directory: &Path, fallback_branch_name: String) -> Result<()>; async fn git_clone(&self, abs_work_directory: &Path, repo_url: &str) -> Result<()>; async fn git_config(&self, abs_work_directory: &Path, args: Vec) -> Result; fn is_fake(&self) -> bool; async fn is_case_sensitive(&self) -> bool; fn subscribe_to_jobs(&self) -> JobEventReceiver; /// Restores a given `TrashedEntry`, moving it from the system's trash back /// to the original path. async fn restore( &self, trashed_entry: TrashedEntry, ) -> std::result::Result; #[cfg(feature = "test-support")] fn as_fake(&self) -> Arc { panic!("called as_fake on a real fs"); } } // We use our own type rather than `trash::TrashItem` directly to avoid carrying // over fields we don't need (e.g. `time_deleted`) and to insulate callers and // tests from changes to that crate's API surface. /// Represents a file or directory that has been moved to the system trash, /// retaining enough information to restore it to its original location. #[derive(Clone, PartialEq, Debug)] pub struct TrashedEntry { /// Platform-specific identifier for the file/directory in the trash. /// /// * Freedesktop – Path to the `.trashinfo` file. /// * macOS & Windows – Full path to the file/directory in the system's /// trash. pub id: OsString, /// Name of the file/directory at the time of trashing, including extension. pub name: OsString, /// Absolute path to the parent directory at the time of trashing. pub original_parent: PathBuf, } impl From for TrashedEntry { fn from(item: trash::TrashItem) -> Self { Self { id: item.id, name: item.name, original_parent: item.original_parent, } } } impl TrashedEntry { fn into_trash_item(self) -> trash::TrashItem { trash::TrashItem { id: self.id, name: self.name, original_parent: self.original_parent, // `TrashedEntry` doesn't preserve `time_deleted` as we don't // currently need it for restore, so we default it to 0 here. time_deleted: 0, } } } #[derive(Debug, thiserror::Error)] pub enum TrashRestoreError { #[error("The specified `path` ({}) was not found in the system's trash.", path.display())] NotFound { path: PathBuf }, #[error("File or directory ({}) already exists at the restore destination.", path.display())] Collision { path: PathBuf }, #[error("Unknown error ({description})")] Unknown { description: String }, } impl From for TrashRestoreError { fn from(err: trash::Error) -> Self { match err { trash::Error::RestoreCollision { path, .. } => Self::Collision { path }, trash::Error::Unknown { description } => Self::Unknown { description }, other => Self::Unknown { description: other.to_string(), }, } } } struct GlobalFs(Arc); impl Global for GlobalFs {} impl dyn Fs { /// Returns the global [`Fs`]. pub fn global(cx: &App) -> Arc { GlobalFs::global(cx).0.clone() } /// Sets the global [`Fs`]. pub fn set_global(fs: Arc, cx: &mut App) { cx.set_global(GlobalFs(fs)); } } #[derive(Copy, Clone, Default)] pub struct CreateOptions { pub overwrite: bool, pub ignore_if_exists: bool, } #[derive(Copy, Clone, Default)] pub struct CopyOptions { pub overwrite: bool, pub ignore_if_exists: bool, } #[derive(Copy, Clone, Default)] pub struct RenameOptions { pub overwrite: bool, pub ignore_if_exists: bool, /// Whether to create parent directories if they do not exist. pub create_parents: bool, } #[derive(Copy, Clone, Default)] pub struct RemoveOptions { pub recursive: bool, pub ignore_if_not_exists: bool, } #[derive(Copy, Clone, Debug)] pub struct Metadata { pub inode: u64, pub mtime: MTime, pub is_symlink: bool, pub is_dir: bool, pub len: u64, pub is_fifo: bool, pub is_executable: bool, } /// Filesystem modification time. The purpose of this newtype is to discourage use of operations /// that do not make sense for mtimes. In particular, it is not always valid to compare mtimes using /// `<` or `>`, as there are many things that can cause the mtime of a file to be earlier than it /// was. See ["mtime comparison considered harmful" - apenwarr](https://apenwarr.ca/log/20181113). /// /// Do not derive Ord, PartialOrd, or arithmetic operation traits. #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)] #[serde(transparent)] pub struct MTime(SystemTime); pub type JobId = usize; #[derive(Clone, Debug)] pub struct JobInfo { pub start: Instant, pub message: SharedString, pub id: JobId, } #[derive(Debug, Clone)] pub enum JobEvent { Started { info: JobInfo }, Completed { id: JobId }, } pub type JobEventSender = futures::channel::mpsc::UnboundedSender; pub type JobEventReceiver = futures::channel::mpsc::UnboundedReceiver; struct JobTracker { id: JobId, subscribers: Arc>>, } impl JobTracker { fn new(info: JobInfo, subscribers: Arc>>) -> Self { let id = info.id; { let mut subs = subscribers.lock(); subs.retain(|sender| { sender .unbounded_send(JobEvent::Started { info: info.clone() }) .is_ok() }); } Self { id, subscribers } } } impl Drop for JobTracker { fn drop(&mut self) { let mut subs = self.subscribers.lock(); subs.retain(|sender| { sender .unbounded_send(JobEvent::Completed { id: self.id }) .is_ok() }); } } impl MTime { /// Conversion intended for persistence and testing. pub fn from_seconds_and_nanos(secs: u64, nanos: u32) -> Self { MTime(UNIX_EPOCH + Duration::new(secs, nanos)) } /// Conversion intended for persistence. pub fn to_seconds_and_nanos_for_persistence(self) -> Option<(u64, u32)> { self.0 .duration_since(UNIX_EPOCH) .ok() .map(|duration| (duration.as_secs(), duration.subsec_nanos())) } /// Returns the value wrapped by this `MTime`, for presentation to the user. The name including /// "_for_user" is to discourage misuse - this method should not be used when making decisions /// about file dirtiness. pub fn timestamp_for_user(self) -> SystemTime { self.0 } /// Temporary method to split out the behavior changes from introduction of this newtype. pub fn bad_is_greater_than(self, other: MTime) -> bool { self.0 > other.0 } } impl From for MTime { fn from(timestamp: proto::Timestamp) -> Self { MTime(timestamp.into()) } } impl From for proto::Timestamp { fn from(mtime: MTime) -> Self { mtime.0.into() } } pub struct RealFs { bundled_git_binary_path: Option, executor: BackgroundExecutor, next_job_id: Arc, job_event_subscribers: Arc>>, is_case_sensitive: AtomicU8, } pub trait FileHandle: Send + Sync + std::fmt::Debug { fn current_path(&self, fs: &Arc) -> Result; } impl FileHandle for std::fs::File { #[cfg(target_os = "macos")] fn current_path(&self, _: &Arc) -> Result { use std::{ ffi::{CStr, OsStr}, os::unix::ffi::OsStrExt, }; let fd = self.as_fd(); let mut path_buf = MaybeUninit::<[u8; libc::PATH_MAX as usize]>::uninit(); let result = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETPATH, path_buf.as_mut_ptr()) }; anyhow::ensure!(result != -1, "fcntl returned -1"); // SAFETY: `fcntl` will initialize the path buffer. let c_str = unsafe { CStr::from_ptr(path_buf.as_ptr().cast()) }; anyhow::ensure!(!c_str.is_empty(), "Could find a path for the file handle"); let path = PathBuf::from(OsStr::from_bytes(c_str.to_bytes())); Ok(path) } #[cfg(target_os = "linux")] fn current_path(&self, _: &Arc) -> Result { 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 = "freebsd")] fn current_path(&self, _: &Arc) -> Result { use std::{ ffi::{CStr, OsStr}, os::unix::ffi::OsStrExt, }; let fd = self.as_fd(); let mut kif = MaybeUninit::::uninit(); kif.kf_structsize = libc::KINFO_FILE_SIZE; let result = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_KINFO, kif.as_mut_ptr()) }; anyhow::ensure!(result != -1, "fcntl returned -1"); // SAFETY: `fcntl` will initialize the kif. let c_str = unsafe { CStr::from_ptr(kif.assume_init().kf_path.as_ptr()) }; anyhow::ensure!(!c_str.is_empty(), "Could find a path for the file handle"); let path = PathBuf::from(OsStr::from_bytes(c_str.to_bytes())); Ok(path) } #[cfg(target_os = "windows")] fn current_path(&self, _: &Arc) -> Result { use std::ffi::OsString; use std::os::windows::ffi::OsStringExt; use std::os::windows::io::AsRawHandle; use windows::Win32::Foundation::HANDLE; use windows::Win32::Storage::FileSystem::{ FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW, }; let handle = HANDLE(self.as_raw_handle() as _); // Query required buffer size (in wide chars) let required_len = unsafe { GetFinalPathNameByHandleW(handle, &mut [], FILE_NAME_NORMALIZED) }; anyhow::ensure!( required_len != 0, "GetFinalPathNameByHandleW returned 0 length" ); // Allocate buffer and retrieve the path let mut buf: Vec = vec![0u16; required_len as usize + 1]; let written = unsafe { GetFinalPathNameByHandleW(handle, &mut buf, FILE_NAME_NORMALIZED) }; anyhow::ensure!( written != 0, "GetFinalPathNameByHandleW failed to write path" ); let os_str: OsString = OsString::from_wide(&buf[..written as usize]); anyhow::ensure!(!os_str.is_empty(), "Could find a path for the file handle"); Ok(PathBuf::from(os_str)) } } pub struct RealWatcher {} impl RealFs { pub fn new(git_binary_path: Option, executor: BackgroundExecutor) -> Self { Self { bundled_git_binary_path: git_binary_path, executor, next_job_id: Arc::new(AtomicUsize::new(0)), job_event_subscribers: Arc::new(Mutex::new(Vec::new())), is_case_sensitive: Default::default(), } } #[cfg(target_os = "windows")] fn canonicalize(path: &Path) -> Result { use std::ffi::OsString; use std::os::windows::ffi::OsStringExt; use windows::Win32::Storage::FileSystem::GetVolumePathNameW; use windows::core::HSTRING; // std::fs::canonicalize resolves mapped network paths to UNC paths, which can // confuse some software. To mitigate this, we canonicalize the input, then rebase // the result onto the input's original volume root if both paths are on the same // volume. This keeps the same drive letter or mount point the caller used. let abs_path = if path.is_relative() { std::env::current_dir()?.join(path) } else { path.to_path_buf() }; let path_hstring = HSTRING::from(abs_path.as_os_str()); let mut vol_buf = vec![0u16; abs_path.as_os_str().len() + 2]; unsafe { GetVolumePathNameW(&path_hstring, &mut vol_buf)? }; let volume_root = { let len = vol_buf .iter() .position(|&c| c == 0) .unwrap_or(vol_buf.len()); PathBuf::from(OsString::from_wide(&vol_buf[..len])) }; let resolved_path = dunce::canonicalize(&abs_path)?; let resolved_root = dunce::canonicalize(&volume_root)?; if let Ok(relative) = resolved_path.strip_prefix(&resolved_root) { let mut result = volume_root; result.push(relative); Ok(result) } else { Ok(resolved_path) } } } #[cfg(any(target_os = "macos", target_os = "linux"))] fn rename_without_replace(source: &Path, target: &Path) -> io::Result<()> { let source = path_to_c_string(source)?; let target = path_to_c_string(target)?; #[cfg(target_os = "macos")] let result = unsafe { libc::renamex_np(source.as_ptr(), target.as_ptr(), libc::RENAME_EXCL) }; #[cfg(target_os = "linux")] let result = unsafe { libc::syscall( libc::SYS_renameat2, libc::AT_FDCWD, source.as_ptr(), libc::AT_FDCWD, target.as_ptr(), libc::RENAME_NOREPLACE, ) }; if result == 0 { Ok(()) } else { Err(io::Error::last_os_error()) } } #[cfg(target_os = "windows")] fn rename_without_replace(source: &Path, target: &Path) -> io::Result<()> { use std::os::windows::ffi::OsStrExt; use windows::Win32::Storage::FileSystem::{MOVE_FILE_FLAGS, MoveFileExW}; use windows::core::PCWSTR; let source: Vec = source.as_os_str().encode_wide().chain(Some(0)).collect(); let target: Vec = target.as_os_str().encode_wide().chain(Some(0)).collect(); unsafe { MoveFileExW( PCWSTR(source.as_ptr()), PCWSTR(target.as_ptr()), MOVE_FILE_FLAGS::default(), ) } .map_err(|_| io::Error::last_os_error()) } #[cfg(any(target_os = "macos", target_os = "linux"))] fn path_to_c_string(path: &Path) -> io::Result { CString::new(path.as_os_str().as_bytes()).map_err(|_| { io::Error::new( io::ErrorKind::InvalidInput, format!("path contains interior NUL: {}", path.display()), ) }) } #[async_trait::async_trait] impl Fs for RealFs { async fn create_dir(&self, path: &Path) -> Result<()> { Ok(smol::fs::create_dir_all(path).await?) } async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()> { #[cfg(unix)] smol::fs::unix::symlink(target, path).await?; #[cfg(windows)] if smol::fs::metadata(&target).await?.is_dir() { let status = new_command("cmd") .args(["/C", "mklink", "/J"]) .args([path, target.as_path()]) .status() .await?; if !status.success() { return Err(anyhow::anyhow!( "Failed to create junction from {:?} to {:?}", path, target )); } } else { smol::fs::windows::symlink_file(target, path).await? } Ok(()) } async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()> { let mut open_options = smol::fs::OpenOptions::new(); open_options.write(true).create(true); if options.overwrite { open_options.truncate(true); } else if !options.ignore_if_exists { open_options.create_new(true); } open_options .open(path) .await .with_context(|| format!("Failed to create file at {:?}", path))?; Ok(()) } async fn create_file_with( &self, path: &Path, content: Pin<&mut (dyn AsyncRead + Send)>, ) -> Result<()> { let mut file = smol::fs::File::create(&path) .await .with_context(|| format!("Failed to create file at {:?}", path))?; futures::io::copy(content, &mut file).await?; Ok(()) } async fn extract_tar_file( &self, path: &Path, content: Archive>, ) -> Result<()> { content.unpack(path).await?; Ok(()) } async fn copy_file(&self, source: &Path, target: &Path, options: CopyOptions) -> Result<()> { if !options.overwrite && smol::fs::metadata(target).await.is_ok() { if options.ignore_if_exists { return Ok(()); } else { anyhow::bail!("{target:?} already exists"); } } smol::fs::copy(source, target).await?; Ok(()) } async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()> { if options.create_parents { if let Some(parent) = target.parent() { self.create_dir(parent).await?; } } if options.overwrite { smol::fs::rename(source, target).await?; return Ok(()); } let use_metadata_fallback = { #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] { let source = source.to_path_buf(); let target = target.to_path_buf(); match self .executor .spawn(async move { rename_without_replace(&source, &target) }) .await { Ok(()) => return Ok(()), Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { if options.ignore_if_exists { return Ok(()); } return Err(error.into()); } Err(error) if error.raw_os_error().is_some_and(|code| { code == libc::ENOSYS || code == libc::ENOTSUP || code == libc::EOPNOTSUPP || code == libc::EINVAL }) => { // For case when filesystem or kernel does not support atomic no-overwrite rename. // EINVAL is returned by FUSE-based filesystems (e.g. NTFS via ntfs-3g) // that don't support RENAME_NOREPLACE. true } Err(error) => return Err(error.into()), } } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] { // For platforms which do not have an atomic no-overwrite rename yet. true } }; if use_metadata_fallback && smol::fs::metadata(target).await.is_ok() { if options.ignore_if_exists { return Ok(()); } else { anyhow::bail!("{target:?} already exists"); } } smol::fs::rename(source, target).await?; Ok(()) } async fn remove_dir(&self, path: &Path, options: RemoveOptions) -> Result<()> { let result = if options.recursive { smol::fs::remove_dir_all(path).await } else { smol::fs::remove_dir(path).await }; match result { Ok(()) => Ok(()), Err(err) if err.kind() == io::ErrorKind::NotFound && options.ignore_if_not_exists => { Ok(()) } Err(err) => Err(err)?, } } async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()> { #[cfg(windows)] if let Ok(Some(metadata)) = self.metadata(path).await && metadata.is_symlink && metadata.is_dir { self.remove_dir( path, RemoveOptions { recursive: false, ignore_if_not_exists: true, }, ) .await?; return Ok(()); } match smol::fs::remove_file(path).await { Ok(()) => Ok(()), Err(err) if err.kind() == io::ErrorKind::NotFound && options.ignore_if_not_exists => { Ok(()) } Err(err) => Err(err)?, } } async fn trash(&self, path: &Path, _options: RemoveOptions) -> Result { // We must make the path absolute or trash will make a weird abomination // of the zed working directory (not usually the worktree) and whatever // the path variable holds. let path = self .canonicalize(path) .await .context("Could not canonicalize the path of the file")?; let (tx, rx) = futures::channel::oneshot::channel(); std::thread::Builder::new() .name("trash file or dir".to_string()) .spawn(|| tx.send(trash::delete_with_info(path))) .expect("The os can spawn threads"); Ok(rx .await .context("Tx dropped or fs.restore panicked")? .context("Could not trash file or dir")? .into()) } async fn open_sync(&self, path: &Path) -> Result> { Ok(Box::new(std::fs::File::open(path)?)) } async fn open_handle(&self, path: &Path) -> Result> { let mut options = std::fs::OpenOptions::new(); options.read(true); #[cfg(windows)] { use std::os::windows::fs::OpenOptionsExt; options.custom_flags(windows::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS.0); } Ok(Arc::new(options.open(path)?)) } async fn load(&self, path: &Path) -> Result { let path = path.to_path_buf(); self.executor .spawn(async move { std::fs::read_to_string(&path) .with_context(|| format!("Failed to read file {}", path.display())) }) .await } async fn load_bytes(&self, path: &Path) -> Result> { let path = path.to_path_buf(); let bytes = self .executor .spawn(async move { std::fs::read(path) }) .await?; Ok(bytes) } #[cfg(not(target_os = "windows"))] async fn atomic_write(&self, path: PathBuf, data: String) -> Result<()> { smol::unblock(move || { // Use the directory of the destination as temp dir to avoid // invalid cross-device link error, and XDG_CACHE_DIR for fallback. // See https://github.com/zed-industries/zed/pull/8437 for more details. let mut tmp_file = tempfile::NamedTempFile::new_in(path.parent().unwrap_or(paths::temp_dir()))?; tmp_file.write_all(data.as_bytes())?; tmp_file.persist(path)?; anyhow::Ok(()) }) .await?; Ok(()) } #[cfg(target_os = "windows")] async fn atomic_write(&self, path: PathBuf, data: String) -> Result<()> { smol::unblock(move || { // If temp dir is set to a different drive than the destination, // we receive error: // // failed to persist temporary file: // The system cannot move the file to a different disk drive. (os error 17) // // This is because `ReplaceFileW` does not support cross volume moves. // See the remark section: "The backup file, replaced file, and replacement file must all reside on the same volume." // https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew#remarks // // So we use the directory of the destination as a temp dir to avoid it. // https://github.com/zed-industries/zed/issues/16571 let temp_dir = TempDir::new_in(path.parent().unwrap_or(paths::temp_dir()))?; let temp_file = { let temp_file_path = temp_dir.path().join("temp_file"); let mut file = std::fs::File::create_new(&temp_file_path)?; file.write_all(data.as_bytes())?; temp_file_path }; atomic_replace(path.as_path(), temp_file.as_path())?; anyhow::Ok(()) }) .await?; Ok(()) } async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> { let buffer_size = text.summary().len.min(10 * 1024); if let Some(path) = path.parent() { self.create_dir(path) .await .with_context(|| format!("Failed to create directory at {:?}", path))?; } let file = smol::fs::File::create(path) .await .with_context(|| format!("Failed to create file at {:?}", path))?; let mut writer = smol::io::BufWriter::with_capacity(buffer_size, file); for chunk in text::chunks_with_line_ending(text, line_ending) { writer.write_all(chunk.as_bytes()).await?; } writer.flush().await?; Ok(()) } async fn write(&self, path: &Path, content: &[u8]) -> Result<()> { if let Some(path) = path.parent() { self.create_dir(path) .await .with_context(|| format!("Failed to create directory at {:?}", path))?; } let path = path.to_owned(); let contents = content.to_owned(); self.executor .spawn(async move { std::fs::write(path, contents)?; Ok(()) }) .await } async fn canonicalize(&self, path: &Path) -> Result { let path = path.to_owned(); self.executor .spawn(async move { #[cfg(target_os = "windows")] let result = Self::canonicalize(&path); #[cfg(not(target_os = "windows"))] let result = std::fs::canonicalize(&path); result.with_context(|| format!("canonicalizing {path:?}")) }) .await } async fn is_file(&self, path: &Path) -> bool { let path = path.to_owned(); self.executor .spawn(async move { std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) }) .await } async fn is_dir(&self, path: &Path) -> bool { let path = path.to_owned(); self.executor .spawn(async move { std::fs::metadata(path).is_ok_and(|metadata| metadata.is_dir()) }) .await } async fn metadata(&self, path: &Path) -> Result> { let path_buf = path.to_owned(); let symlink_metadata = match self .executor .spawn(async move { std::fs::symlink_metadata(&path_buf) }) .await { Ok(metadata) => metadata, Err(err) => { return match err.kind() { io::ErrorKind::NotFound | io::ErrorKind::NotADirectory => Ok(None), _ => Err(anyhow::Error::new(err)), }; } }; let is_symlink = symlink_metadata.file_type().is_symlink(); let metadata = if is_symlink { let path_buf = path.to_path_buf(); // Read target metadata, if the target exists match self .executor .spawn(async move { std::fs::metadata(path_buf) }) .await { Ok(target_metadata) => target_metadata, Err(err) => { if err.kind() != io::ErrorKind::NotFound { // TODO: Also FilesystemLoop when that's stable log::warn!( "Failed to read symlink target metadata for path {path:?}: {err}" ); } // For a broken or recursive symlink, return the symlink metadata. (Or // as edge cases, a symlink into a directory we can't read, which is hard // to distinguish from just being broken.) symlink_metadata } } } else { symlink_metadata }; #[cfg(unix)] let inode = metadata.ino(); #[cfg(windows)] let inode = file_id(path).await?; #[cfg(windows)] let is_fifo = false; #[cfg(unix)] let is_fifo = metadata.file_type().is_fifo(); let path_buf = path.to_path_buf(); let is_executable = self .executor .spawn(async move { path_buf.is_executable() }) .await; Ok(Some(Metadata { inode, mtime: MTime(metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH)), len: metadata.len(), is_symlink, is_dir: metadata.file_type().is_dir(), is_fifo, is_executable, })) } async fn read_link(&self, path: &Path) -> Result { let path = path.to_owned(); let path = self .executor .spawn(async move { std::fs::read_link(&path) }) .await?; Ok(path) } async fn read_dir( &self, path: &Path, ) -> Result>>>> { let path = path.to_owned(); let result = iter( self.executor .spawn(async move { std::fs::read_dir(path) }) .await?, ) .map(|entry| match entry { Ok(entry) => Ok(entry.path()), Err(error) => Err(anyhow!("failed to read dir entry {error:?}")), }); Ok(Box::pin(result)) } async fn watch( &self, path: &Path, latency: Duration, ) -> ( Pin>>>, Arc, ) { use util::{ResultExt as _, paths::SanitizedPath}; let executor = self.executor.clone(); let use_poll = requires_poll_watcher(path); let watch_path = effective_watch_path(path); let (tx, rx) = async_channel::unbounded(); let pending_paths: Arc>> = Default::default(); let mode = if use_poll { log::info!( "Using poll watcher ({}ms interval) for {}", fs_watcher::poll_interval().as_millis(), path.display() ); telemetry::event!("fs_watcher_poll", path = path.display().to_string()); fs_watcher::WatcherMode::Poll } else { fs_watcher::WatcherMode::Native }; let watcher: Arc = Arc::new(fs_watcher::FsWatcher::new( tx.clone(), pending_paths.clone(), mode, )); if let Err(e) = watcher.add(&watch_path) { log::warn!( "Failed to watch {} using {}:\n{e}", path.display(), watch_path.display() ); } // Check if path is a symlink and follow the target parent if let Some(mut target) = self.read_link(path).await.ok() { log::trace!("watch symlink {path:?} -> {target:?}"); // Check if symlink target is relative path, if so make it absolute if target.is_relative() && let Some(parent) = path.parent() { target = parent.join(target); if let Ok(canonical) = self.canonicalize(&target).await { target = SanitizedPath::new(&canonical).as_path().to_path_buf(); } } watcher.add(&target).ok(); if let Some(parent) = target.parent() { watcher.add(parent).log_err(); } } ( Box::pin(rx.filter_map({ let watcher = watcher.clone(); let executor = executor.clone(); move |_| { let _ = watcher.clone(); let pending_paths = pending_paths.clone(); let executor = executor.clone(); async move { executor.timer(latency).await; let paths = std::mem::take(&mut *pending_paths.lock()); (!paths.is_empty()).then_some(paths) } } })), watcher, ) } fn open_repo( &self, dotgit_path: &Path, system_git_binary_path: Option<&Path>, ) -> Result> { Ok(Arc::new(RealGitRepository::new( dotgit_path, self.bundled_git_binary_path.clone(), system_git_binary_path.map(|path| path.to_path_buf()), self.executor.clone(), )?)) } async fn git_init( &self, abs_work_directory_path: &Path, fallback_branch_name: String, ) -> Result<()> { let result = new_command("git") .current_dir(abs_work_directory_path) .args(&["config", "--global", "--get", "init.defaultBranch"]) .output() .await; // In case the `git config` command fails, which would be the case if // the user doesn't have an `init.defaultBranch` value set, we'll just // default to the provided `fallback_branch_name`. let branch_name = match result { Ok(output) if !output.stdout.is_empty() => String::from_utf8(output.stdout)?, _ => fallback_branch_name, }; new_command("git") .current_dir(abs_work_directory_path) .args(&["init", "-b"]) .arg(branch_name.trim()) .output() .await?; Ok(()) } async fn git_clone(&self, abs_work_directory: &Path, repo_url: &str) -> Result<()> { let job_id = self.next_job_id.fetch_add(1, Ordering::SeqCst); let job_info = JobInfo { id: job_id, start: Instant::now(), message: SharedString::from(format!("Cloning {}", repo_url)), }; let _job_tracker = JobTracker::new(job_info, self.job_event_subscribers.clone()); let output = new_command("git") .current_dir(abs_work_directory) .args(&["clone", repo_url]) .output() .await?; if !output.status.success() { anyhow::bail!( "git clone failed: {}", String::from_utf8_lossy(&output.stderr) ); } Ok(()) } /// Runs `git config` with the given arguments. /// Will return `Ok` if the commands exit status is `0`, with the stdout /// contents. Otherwise returns `Err` with the stderr contents. async fn git_config(&self, abs_work_directory: &Path, args: Vec) -> Result { let output = new_command("git") .current_dir(abs_work_directory) .args([String::from("config")].into_iter().chain(args)) .output() .await?; if !output.status.success() { let err = String::from_utf8(output.stderr)?; anyhow::bail!(err); } String::from_utf8(output.stdout).map_err(Into::into) } fn is_fake(&self) -> bool { false } fn subscribe_to_jobs(&self) -> JobEventReceiver { let (sender, receiver) = futures::channel::mpsc::unbounded(); self.job_event_subscribers.lock().push(sender); receiver } /// Checks whether the file system is case sensitive by attempting to create two files /// that have the same name except for the casing. /// /// It creates both files in a temporary directory it removes at the end. async fn is_case_sensitive(&self) -> bool { const UNINITIALIZED: u8 = 0; const CASE_SENSITIVE: u8 = 1; const NOT_CASE_SENSITIVE: u8 = 2; // Note we could CAS here, but really, if we race we do this work twice at worst which isn't a big deal. let load = self.is_case_sensitive.load(Ordering::Acquire); if load != UNINITIALIZED { return load == CASE_SENSITIVE; } let temp_dir = self.executor.spawn(async { TempDir::new() }); let res = maybe!(async { let temp_dir = temp_dir.await?; let test_file_1 = temp_dir.path().join("case_sensitivity_test.tmp"); let test_file_2 = temp_dir.path().join("CASE_SENSITIVITY_TEST.TMP"); let create_opts = CreateOptions { overwrite: false, ignore_if_exists: false, }; // Create file1 self.create_file(&test_file_1, create_opts).await?; // Now check whether it's possible to create file2 let case_sensitive = match self.create_file(&test_file_2, create_opts).await { Ok(_) => Ok(true), Err(e) => { if let Some(io_error) = e.downcast_ref::() { if io_error.kind() == io::ErrorKind::AlreadyExists { Ok(false) } else { Err(e) } } else { Err(e) } } }; temp_dir.close()?; case_sensitive }).await.unwrap_or_else(|e| { log::error!( "Failed to determine whether filesystem is case sensitive (falling back to true) due to error: {e:#}" ); true }); self.is_case_sensitive.store( if res { CASE_SENSITIVE } else { NOT_CASE_SENSITIVE }, Ordering::Release, ); res } async fn restore( &self, trashed_entry: TrashedEntry, ) -> std::result::Result { let restored_item_path = trashed_entry.original_parent.join(&trashed_entry.name); let (tx, rx) = futures::channel::oneshot::channel(); std::thread::Builder::new() .name("restore trashed item".to_string()) .spawn(move || { let res = trash::restore_all([trashed_entry.into_trash_item()]); tx.send(res) }) .expect("The OS can spawn a threads"); rx.await.expect("Restore all never panics")?; Ok(restored_item_path) } } #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] impl Watcher for RealWatcher { fn add(&self, _: &Path) -> Result<()> { Ok(()) } fn remove(&self, _: &Path) -> Result<()> { Ok(()) } } #[cfg(feature = "test-support")] pub struct FakeFs { this: std::sync::Weak, // Use an unfair lock to ensure tests are deterministic. state: Arc>, executor: gpui::BackgroundExecutor, } #[cfg(feature = "test-support")] struct FakeFsState { root: FakeFsEntry, next_inode: u64, next_mtime: SystemTime, git_event_tx: async_channel::Sender, event_txs: Vec<(PathBuf, async_channel::Sender>)>, events_paused: bool, buffered_events: Vec, metadata_call_count: usize, read_dir_call_count: usize, path_write_counts: std::collections::HashMap, moves: std::collections::HashMap, job_event_subscribers: Arc>>, trash: Vec<(TrashedEntry, FakeFsEntry)>, } #[cfg(feature = "test-support")] #[derive(Clone, Debug)] enum FakeFsEntry { File { inode: u64, mtime: MTime, len: u64, content: Vec, // The path to the repository state directory, if this is a gitfile. git_dir_path: Option, }, Dir { inode: u64, mtime: MTime, len: u64, entries: BTreeMap, git_repo_state: Option>>, }, Symlink { target: PathBuf, }, } #[cfg(feature = "test-support")] impl PartialEq for FakeFsEntry { fn eq(&self, other: &Self) -> bool { match (self, other) { ( Self::File { inode: l_inode, mtime: l_mtime, len: l_len, content: l_content, git_dir_path: l_git_dir_path, }, Self::File { inode: r_inode, mtime: r_mtime, len: r_len, content: r_content, git_dir_path: r_git_dir_path, }, ) => { l_inode == r_inode && l_mtime == r_mtime && l_len == r_len && l_content == r_content && l_git_dir_path == r_git_dir_path } ( Self::Dir { inode: l_inode, mtime: l_mtime, len: l_len, entries: l_entries, git_repo_state: l_git_repo_state, }, Self::Dir { inode: r_inode, mtime: r_mtime, len: r_len, entries: r_entries, git_repo_state: r_git_repo_state, }, ) => { let same_repo_state = match (l_git_repo_state.as_ref(), r_git_repo_state.as_ref()) { (Some(l), Some(r)) => Arc::ptr_eq(l, r), (None, None) => true, _ => false, }; l_inode == r_inode && l_mtime == r_mtime && l_len == r_len && l_entries == r_entries && same_repo_state } (Self::Symlink { target: l_target }, Self::Symlink { target: r_target }) => { l_target == r_target } _ => false, } } } #[cfg(feature = "test-support")] impl FakeFsState { fn get_and_increment_mtime(&mut self) -> MTime { let mtime = self.next_mtime; self.next_mtime += FakeFs::SYSTEMTIME_INTERVAL; MTime(mtime) } fn get_and_increment_inode(&mut self) -> u64 { let inode = self.next_inode; self.next_inode += 1; inode } fn canonicalize(&self, target: &Path, follow_symlink: bool) -> Option { let mut canonical_path = PathBuf::new(); let mut path = target.to_path_buf(); let mut entry_stack = Vec::new(); 'outer: loop { let mut path_components = path.components().peekable(); let mut prefix = None; while let Some(component) = path_components.next() { match component { Component::Prefix(prefix_component) => prefix = Some(prefix_component), Component::RootDir => { entry_stack.clear(); entry_stack.push(&self.root); canonical_path.clear(); match prefix { Some(prefix_component) => { canonical_path = PathBuf::from(prefix_component.as_os_str()); // Prefixes like `C:\\` are represented without their trailing slash, so we have to re-add it. canonical_path.push(std::path::MAIN_SEPARATOR_STR); } None => canonical_path = PathBuf::from(std::path::MAIN_SEPARATOR_STR), } } Component::CurDir => {} Component::ParentDir => { entry_stack.pop()?; canonical_path.pop(); } Component::Normal(name) => { let current_entry = *entry_stack.last()?; if let FakeFsEntry::Dir { entries, .. } = current_entry { let entry = entries.get(name.to_str().unwrap())?; if (path_components.peek().is_some() || follow_symlink) && let FakeFsEntry::Symlink { target, .. } = entry { let mut target = target.clone(); target.extend(path_components); path = target; continue 'outer; } entry_stack.push(entry); canonical_path = canonical_path.join(name); } else { return None; } } } } break; } if entry_stack.is_empty() { None } else { Some(canonical_path) } } fn try_entry( &mut self, target: &Path, follow_symlink: bool, ) -> Option<(&mut FakeFsEntry, PathBuf)> { let canonical_path = self.canonicalize(target, follow_symlink)?; let mut components = canonical_path .components() .skip_while(|component| matches!(component, Component::Prefix(_))); let Some(Component::RootDir) = components.next() else { panic!( "the path {:?} was not canonicalized properly {:?}", target, canonical_path ) }; let mut entry = &mut self.root; for component in components { match component { Component::Normal(name) => { if let FakeFsEntry::Dir { entries, .. } = entry { entry = entries.get_mut(name.to_str().unwrap())?; } else { return None; } } _ => { panic!( "the path {:?} was not canonicalized properly {:?}", target, canonical_path ) } } } Some((entry, canonical_path)) } fn entry(&mut self, target: &Path) -> Result<&mut FakeFsEntry> { Ok(self .try_entry(target, true) .ok_or_else(|| { anyhow!(io::Error::new( io::ErrorKind::NotFound, format!("not found: {target:?}") )) })? .0) } fn write_path(&mut self, path: &Path, callback: Fn) -> Result where Fn: FnOnce(btree_map::Entry) -> Result, { let path = normalize_path(path); let filename = path.file_name().context("cannot overwrite the root")?; let parent_path = path.parent().unwrap(); let parent = self.entry(parent_path)?; let new_entry = parent .dir_entries(parent_path)? .entry(filename.to_str().unwrap().into()); callback(new_entry) } fn emit_event(&mut self, paths: I) where I: IntoIterator)>, T: Into, { self.buffered_events .extend(paths.into_iter().map(|(path, kind)| PathEvent { path: path.into(), kind, })); if !self.events_paused { self.flush_events(self.buffered_events.len()); } } fn flush_events(&mut self, mut count: usize) { count = count.min(self.buffered_events.len()); let events = self.buffered_events.drain(0..count).collect::>(); self.event_txs.retain(|(_, tx)| { let _ = tx.try_send(events.clone()); !tx.is_closed() }); } } #[cfg(feature = "test-support")] pub static FS_DOT_GIT: std::sync::LazyLock<&'static OsStr> = std::sync::LazyLock::new(|| OsStr::new(".git")); #[cfg(feature = "test-support")] impl FakeFs { /// We need to use something large enough for Windows and Unix to consider this a new file. /// https://doc.rust-lang.org/nightly/std/time/struct.SystemTime.html#platform-specific-behavior const SYSTEMTIME_INTERVAL: Duration = Duration::from_nanos(100); pub fn new(executor: gpui::BackgroundExecutor) -> Arc { let (tx, rx) = async_channel::bounded::(10); let this = Arc::new_cyclic(|this| Self { this: this.clone(), executor: executor.clone(), state: Arc::new(Mutex::new(FakeFsState { root: FakeFsEntry::Dir { inode: 0, mtime: MTime(UNIX_EPOCH), len: 0, entries: Default::default(), git_repo_state: None, }, git_event_tx: tx, next_mtime: UNIX_EPOCH + Self::SYSTEMTIME_INTERVAL, next_inode: 1, event_txs: Default::default(), buffered_events: Vec::new(), events_paused: false, read_dir_call_count: 0, metadata_call_count: 0, path_write_counts: Default::default(), moves: Default::default(), job_event_subscribers: Arc::new(Mutex::new(Vec::new())), trash: Vec::new(), })), }); executor.spawn({ let this = this.clone(); async move { while let Ok(git_event) = rx.recv().await { if let Some(mut state) = this.state.try_lock() { state.emit_event([(git_event, Some(PathEventKind::Changed))]); } else { panic!("Failed to lock file system state, this execution would have caused a test hang"); } } } }).detach(); this } pub fn set_next_mtime(&self, next_mtime: SystemTime) { let mut state = self.state.lock(); state.next_mtime = next_mtime; } pub fn get_and_increment_mtime(&self) -> MTime { let mut state = self.state.lock(); state.get_and_increment_mtime() } pub async fn touch_path(&self, path: impl AsRef) { let mut state = self.state.lock(); let path = path.as_ref(); let new_mtime = state.get_and_increment_mtime(); let new_inode = state.get_and_increment_inode(); state .write_path(path, move |entry| { match entry { btree_map::Entry::Vacant(e) => { e.insert(FakeFsEntry::File { inode: new_inode, mtime: new_mtime, content: Vec::new(), len: 0, git_dir_path: None, }); } btree_map::Entry::Occupied(mut e) => match &mut *e.get_mut() { FakeFsEntry::File { mtime, .. } => *mtime = new_mtime, FakeFsEntry::Dir { mtime, .. } => *mtime = new_mtime, FakeFsEntry::Symlink { .. } => {} }, } Ok(()) }) .unwrap(); state.emit_event([(path.to_path_buf(), Some(PathEventKind::Changed))]); } pub async fn insert_file(&self, path: impl AsRef, content: Vec) { self.write_file_internal(path, content, true).unwrap() } pub async fn insert_symlink(&self, path: impl AsRef, target: PathBuf) { let mut state = self.state.lock(); let path = path.as_ref(); let file = FakeFsEntry::Symlink { target }; state .write_path(path.as_ref(), move |e| match e { btree_map::Entry::Vacant(e) => { e.insert(file); Ok(()) } btree_map::Entry::Occupied(mut e) => { *e.get_mut() = file; Ok(()) } }) .unwrap(); state.emit_event([(path, Some(PathEventKind::Created))]); } fn write_file_internal( &self, path: impl AsRef, new_content: Vec, recreate_inode: bool, ) -> Result<()> { fn inner( this: &FakeFs, path: &Path, new_content: Vec, recreate_inode: bool, ) -> Result<()> { let mut state = this.state.lock(); let path_buf = path.to_path_buf(); *state.path_write_counts.entry(path_buf).or_insert(0) += 1; let new_inode = state.get_and_increment_inode(); let new_mtime = state.get_and_increment_mtime(); let new_len = new_content.len() as u64; let mut kind = None; state.write_path(path, |entry| { match entry { btree_map::Entry::Vacant(e) => { kind = Some(PathEventKind::Created); e.insert(FakeFsEntry::File { inode: new_inode, mtime: new_mtime, len: new_len, content: new_content, git_dir_path: None, }); } btree_map::Entry::Occupied(mut e) => { kind = Some(PathEventKind::Changed); if let FakeFsEntry::File { inode, mtime, len, content, .. } = e.get_mut() { *mtime = new_mtime; *content = new_content; *len = new_len; if recreate_inode { *inode = new_inode; } } else { anyhow::bail!("not a file") } } } Ok(()) })?; state.emit_event([(path, kind)]); Ok(()) } inner(self, path.as_ref(), new_content, recreate_inode) } pub fn read_file_sync(&self, path: impl AsRef) -> Result> { let path = path.as_ref(); let path = normalize_path(path); let mut state = self.state.lock(); let entry = state.entry(&path)?; entry.file_content(&path).cloned() } async fn load_internal(&self, path: impl AsRef) -> Result> { let path = path.as_ref(); let path = normalize_path(path); self.simulate_random_delay().await; let mut state = self.state.lock(); let entry = state.entry(&path)?; entry.file_content(&path).cloned() } pub fn pause_events(&self) { self.state.lock().events_paused = true; } pub fn unpause_events_and_flush(&self) { self.state.lock().events_paused = false; self.flush_events(usize::MAX); } pub fn buffered_event_count(&self) -> usize { self.state.lock().buffered_events.len() } pub fn clear_buffered_events(&self) { self.state.lock().buffered_events.clear(); } pub fn flush_events(&self, count: usize) { self.state.lock().flush_events(count); } pub(crate) fn entry(&self, target: &Path) -> Result { self.state.lock().entry(target).cloned() } pub(crate) fn insert_entry(&self, target: &Path, new_entry: FakeFsEntry) -> Result<()> { let mut state = self.state.lock(); state.write_path(target, |entry| { match entry { btree_map::Entry::Vacant(vacant_entry) => { vacant_entry.insert(new_entry); } btree_map::Entry::Occupied(mut occupied_entry) => { occupied_entry.insert(new_entry); } } Ok(()) }) } #[must_use] pub fn insert_tree<'a>( &'a self, path: impl 'a + AsRef + Send, tree: serde_json::Value, ) -> futures::future::BoxFuture<'a, ()> { use futures::FutureExt as _; use serde_json::Value::*; fn inner<'a>( this: &'a FakeFs, path: Arc, tree: serde_json::Value, ) -> futures::future::BoxFuture<'a, ()> { async move { match tree { Object(map) => { this.create_dir(&path).await.unwrap(); for (name, contents) in map { let mut path = PathBuf::from(path.as_ref()); path.push(name); this.insert_tree(&path, contents).await; } } Null => { this.create_dir(&path).await.unwrap(); } String(contents) => { this.insert_file(&path, contents.into_bytes()).await; } _ => { panic!("JSON object must contain only objects, strings, or null"); } } } .boxed() } inner(self, Arc::from(path.as_ref()), tree) } pub fn insert_tree_from_real_fs<'a>( &'a self, path: impl 'a + AsRef + Send, src_path: impl 'a + AsRef + Send, ) -> futures::future::BoxFuture<'a, ()> { use futures::FutureExt as _; async move { let path = path.as_ref(); if std::fs::metadata(&src_path).unwrap().is_file() { let contents = std::fs::read(src_path).unwrap(); self.insert_file(path, contents).await; } else { self.create_dir(path).await.unwrap(); for entry in std::fs::read_dir(&src_path).unwrap() { let entry = entry.unwrap(); self.insert_tree_from_real_fs(path.join(entry.file_name()), entry.path()) .await; } } } .boxed() } pub fn with_git_state_and_paths( &self, dot_git: &Path, emit_git_event: bool, f: F, ) -> Result where F: FnOnce(&mut FakeGitRepositoryState, &Path, &Path) -> T, { let mut state = self.state.lock(); let git_event_tx = state.git_event_tx.clone(); let entry = state.entry(dot_git).context("open .git")?; if let FakeFsEntry::Dir { git_repo_state, .. } = entry { let repo_state = git_repo_state.get_or_insert_with(|| { log::debug!("insert git state for {dot_git:?}"); Arc::new(Mutex::new(FakeGitRepositoryState::new(git_event_tx))) }); let mut repo_state = repo_state.lock(); let result = f(&mut repo_state, dot_git, dot_git); drop(repo_state); if emit_git_event { state.emit_event([( dot_git.join("fake_git_repo_event"), Some(PathEventKind::Changed), )]); } Ok(result) } else if let FakeFsEntry::File { content, git_dir_path, .. } = &mut *entry { let path = match git_dir_path { Some(path) => path, None => { let path = std::str::from_utf8(content) .ok() .and_then(|content| content.strip_prefix("gitdir:")) .context("not a valid gitfile")? .trim(); git_dir_path.insert(normalize_path(&dot_git.parent().unwrap().join(path))) } } .clone(); let Some((git_dir_entry, canonical_path)) = state.try_entry(&path, true) else { anyhow::bail!("pointed-to git dir {path:?} not found") }; let FakeFsEntry::Dir { git_repo_state, entries, .. } = git_dir_entry else { anyhow::bail!("gitfile points to a non-directory") }; let common_dir = if let Some(child) = entries.get("commondir") { let raw = std::str::from_utf8(child.file_content("commondir".as_ref())?) .context("commondir content")? .trim(); let raw_path = Path::new(raw); if raw_path.is_relative() { normalize_path(&canonical_path.join(raw_path)) } else { raw_path.to_owned() } } else { canonical_path.clone() }; let repo_state = git_repo_state.get_or_insert_with(|| { Arc::new(Mutex::new(FakeGitRepositoryState::new(git_event_tx))) }); let mut repo_state = repo_state.lock(); let result = f(&mut repo_state, &canonical_path, &common_dir); if emit_git_event { drop(repo_state); state.emit_event([( canonical_path.join("fake_git_repo_event"), Some(PathEventKind::Changed), )]); } Ok(result) } else { anyhow::bail!("not a valid git repository"); } } pub fn with_git_state(&self, dot_git: &Path, emit_git_event: bool, f: F) -> Result where F: FnOnce(&mut FakeGitRepositoryState) -> T, { self.with_git_state_and_paths(dot_git, emit_git_event, |state, _, _| f(state)) } pub fn set_branch_name(&self, dot_git: &Path, branch: Option>) { self.with_git_state(dot_git, true, |state| { let branch = branch.map(Into::into); state.branches.extend(branch.clone()); state.current_branch_name = branch }) .unwrap(); } pub fn set_remote_for_repo( &self, dot_git: &Path, name: impl Into, url: impl Into, ) { self.with_git_state(dot_git, true, |state| { state.remotes.insert(name.into(), url.into()); }) .unwrap(); } pub fn insert_branches(&self, dot_git: &Path, branches: &[&str]) { self.with_git_state(dot_git, true, |state| { if let Some(first) = branches.first() && state.current_branch_name.is_none() { state.current_branch_name = Some(first.to_string()) } state .branches .extend(branches.iter().map(ToString::to_string)); }) .unwrap(); } pub async fn add_linked_worktree_for_repo( &self, dot_git: &Path, emit_git_event: bool, worktree: Worktree, ) { let ref_name = worktree .ref_name .as_ref() .expect("linked worktree must have a ref_name"); let branch_name = ref_name .strip_prefix("refs/heads/") .unwrap_or(ref_name.as_ref()); // Create ref in git state. self.with_git_state(dot_git, false, |state| { state .refs .insert(ref_name.to_string(), worktree.sha.to_string()); }) .unwrap(); // Create .git/worktrees// directory with HEAD, commondir, and gitdir. let worktrees_entry_dir = dot_git.join("worktrees").join(branch_name); self.create_dir(&worktrees_entry_dir).await.unwrap(); self.write_file_internal( worktrees_entry_dir.join("HEAD"), format!("ref: {ref_name}").into_bytes(), false, ) .unwrap(); self.write_file_internal( worktrees_entry_dir.join("commondir"), dot_git.to_string_lossy().into_owned().into_bytes(), false, ) .unwrap(); let worktree_dot_git = worktree.path.join(".git"); self.write_file_internal( worktrees_entry_dir.join("gitdir"), worktree_dot_git.to_string_lossy().into_owned().into_bytes(), false, ) .unwrap(); // Create the worktree checkout directory with a .git file pointing back. self.create_dir(&worktree.path).await.unwrap(); self.write_file_internal( &worktree_dot_git, format!("gitdir: {}", worktrees_entry_dir.display()).into_bytes(), false, ) .unwrap(); if emit_git_event { self.with_git_state(dot_git, true, |_| {}).unwrap(); } } pub async fn remove_worktree_for_repo( &self, dot_git: &Path, emit_git_event: bool, ref_name: &str, ) { let branch_name = ref_name.strip_prefix("refs/heads/").unwrap_or(ref_name); let worktrees_entry_dir = dot_git.join("worktrees").join(branch_name); // Read gitdir to find the worktree checkout path. let gitdir_content = self .load_internal(worktrees_entry_dir.join("gitdir")) .await .unwrap(); let gitdir_str = String::from_utf8(gitdir_content).unwrap(); let worktree_path = PathBuf::from(gitdir_str.trim()) .parent() .map(PathBuf::from) .unwrap_or_default(); // Remove the worktree checkout directory. self.remove_dir( &worktree_path, RemoveOptions { recursive: true, ignore_if_not_exists: true, }, ) .await .unwrap(); // Remove the .git/worktrees// directory. self.remove_dir( &worktrees_entry_dir, RemoveOptions { recursive: true, ignore_if_not_exists: false, }, ) .await .unwrap(); if emit_git_event { self.with_git_state(dot_git, true, |_| {}).unwrap(); } } pub fn set_unmerged_paths_for_repo( &self, dot_git: &Path, unmerged_state: &[(RepoPath, UnmergedStatus)], ) { self.with_git_state(dot_git, true, |state| { state.unmerged_paths.clear(); state.unmerged_paths.extend( unmerged_state .iter() .map(|(path, content)| (path.clone(), *content)), ); }) .unwrap(); } pub fn set_index_for_repo(&self, dot_git: &Path, index_state: &[(&str, String)]) { self.with_git_state(dot_git, true, |state| { state.index_contents.clear(); state.index_contents.extend( index_state .iter() .map(|(path, content)| (repo_path(path), content.clone())), ); }) .unwrap(); } pub fn set_head_for_repo( &self, dot_git: &Path, head_state: &[(&str, String)], sha: impl Into, ) { self.with_git_state(dot_git, true, |state| { state.head_contents.clear(); state.head_contents.extend( head_state .iter() .map(|(path, content)| (repo_path(path), content.clone())), ); state.refs.insert("HEAD".into(), sha.into()); }) .unwrap(); } pub fn set_head_and_index_for_repo(&self, dot_git: &Path, contents_by_path: &[(&str, String)]) { self.with_git_state(dot_git, true, |state| { state.head_contents.clear(); state.head_contents.extend( contents_by_path .iter() .map(|(path, contents)| (repo_path(path), contents.clone())), ); state.index_contents = state.head_contents.clone(); }) .unwrap(); } pub fn set_merge_base_content_for_repo( &self, dot_git: &Path, contents_by_path: &[(&str, String)], ) { self.with_git_state(dot_git, true, |state| { use git::Oid; state.merge_base_contents.clear(); let oids = (1..) .map(|n| n.to_string()) .map(|n| Oid::from_bytes(n.repeat(20).as_bytes()).unwrap()); for ((path, content), oid) in contents_by_path.iter().zip(oids) { state.merge_base_contents.insert(repo_path(path), oid); state.oids.insert(oid, content.clone()); } }) .unwrap(); } pub fn set_blame_for_repo(&self, dot_git: &Path, blames: Vec<(RepoPath, git::blame::Blame)>) { self.with_git_state(dot_git, true, |state| { state.blames.clear(); state.blames.extend(blames); }) .unwrap(); } pub fn set_graph_commits(&self, dot_git: &Path, commits: Vec>) { self.with_git_state(dot_git, true, |state| { state.graph_commits = commits; }) .unwrap(); } pub fn set_graph_error(&self, dot_git: &Path, error: Option) { self.with_git_state(dot_git, true, |state| { state.simulated_graph_error = error; }) .unwrap(); } pub fn set_commit_data( &self, dot_git: &Path, commit_data: impl IntoIterator, ) { self.with_git_state(dot_git, true, |state| { state.commit_data = commit_data .into_iter() .map(|(data, should_fail)| { ( data.sha, if should_fail { FakeCommitDataEntry::Fail(data) } else { FakeCommitDataEntry::Success(data) }, ) }) .collect(); }) .unwrap(); } /// Put the given git repository into a state with the given status, /// by mutating the head, index, and unmerged state. pub fn set_status_for_repo(&self, dot_git: &Path, statuses: &[(&str, FileStatus)]) { let workdir_path = dot_git.parent().unwrap(); let workdir_contents = self.files_with_contents(workdir_path); self.with_git_state(dot_git, true, |state| { state.index_contents.clear(); state.head_contents.clear(); state.unmerged_paths.clear(); for (path, content) in workdir_contents { use util::{paths::PathStyle, rel_path::RelPath}; let repo_path = RelPath::new(path.strip_prefix(&workdir_path).unwrap(), PathStyle::local()).unwrap(); let repo_path = RepoPath::from_rel_path(&repo_path); let status = statuses .iter() .find_map(|(p, status)| (*p == repo_path.as_unix_str()).then_some(status)); let mut content = String::from_utf8_lossy(&content).to_string(); let mut index_content = None; let mut head_content = None; match status { None => { index_content = Some(content.clone()); head_content = Some(content); } Some(FileStatus::Untracked | FileStatus::Ignored) => {} Some(FileStatus::Unmerged(unmerged_status)) => { state .unmerged_paths .insert(repo_path.clone(), *unmerged_status); content.push_str(" (unmerged)"); index_content = Some(content.clone()); head_content = Some(content); } Some(FileStatus::Tracked(TrackedStatus { index_status, worktree_status, })) => { match worktree_status { StatusCode::Modified => { let mut content = content.clone(); content.push_str(" (modified in working copy)"); index_content = Some(content); } StatusCode::TypeChanged | StatusCode::Unmodified => { index_content = Some(content.clone()); } StatusCode::Added => {} StatusCode::Deleted | StatusCode::Renamed | StatusCode::Copied => { panic!("cannot create these statuses for an existing file"); } }; match index_status { StatusCode::Modified => { let mut content = index_content.clone().expect( "file cannot be both modified in index and created in working copy", ); content.push_str(" (modified in index)"); head_content = Some(content); } StatusCode::TypeChanged | StatusCode::Unmodified => { head_content = Some(index_content.clone().expect("file cannot be both unmodified in index and created in working copy")); } StatusCode::Added => {} StatusCode::Deleted => { head_content = Some("".into()); } StatusCode::Renamed | StatusCode::Copied => { panic!("cannot create these statuses for an existing file"); } }; } }; if let Some(content) = index_content { state.index_contents.insert(repo_path.clone(), content); } if let Some(content) = head_content { state.head_contents.insert(repo_path.clone(), content); } } }).unwrap(); } pub fn set_error_message_for_index_write(&self, dot_git: &Path, message: Option) { self.with_git_state(dot_git, true, |state| { state.simulated_index_write_error_message = message; }) .unwrap(); } pub fn set_create_worktree_error(&self, dot_git: &Path, message: Option) { self.with_git_state(dot_git, true, |state| { state.simulated_create_worktree_error = message; }) .unwrap(); } pub fn paths(&self, include_dot_git: bool) -> Vec { let mut result = Vec::new(); let mut queue = collections::VecDeque::new(); let state = &*self.state.lock(); queue.push_back((PathBuf::from(util::path!("/")), &state.root)); while let Some((path, entry)) = queue.pop_front() { if let FakeFsEntry::Dir { entries, .. } = entry { for (name, entry) in entries { queue.push_back((path.join(name), entry)); } } if include_dot_git || !path .components() .any(|component| component.as_os_str() == *FS_DOT_GIT) { result.push(path); } } result } pub fn directories(&self, include_dot_git: bool) -> Vec { let mut result = Vec::new(); let mut queue = collections::VecDeque::new(); let state = &*self.state.lock(); queue.push_back((PathBuf::from(util::path!("/")), &state.root)); while let Some((path, entry)) = queue.pop_front() { if let FakeFsEntry::Dir { entries, .. } = entry { for (name, entry) in entries { queue.push_back((path.join(name), entry)); } if include_dot_git || !path .components() .any(|component| component.as_os_str() == *FS_DOT_GIT) { result.push(path); } } } result } pub fn files(&self) -> Vec { let mut result = Vec::new(); let mut queue = collections::VecDeque::new(); let state = &*self.state.lock(); queue.push_back((PathBuf::from(util::path!("/")), &state.root)); while let Some((path, entry)) = queue.pop_front() { match entry { FakeFsEntry::File { .. } => result.push(path), FakeFsEntry::Dir { entries, .. } => { for (name, entry) in entries { queue.push_back((path.join(name), entry)); } } FakeFsEntry::Symlink { .. } => {} } } result } pub fn files_with_contents(&self, prefix: &Path) -> Vec<(PathBuf, Vec)> { let mut result = Vec::new(); let mut queue = collections::VecDeque::new(); let state = &*self.state.lock(); queue.push_back((PathBuf::from(util::path!("/")), &state.root)); while let Some((path, entry)) = queue.pop_front() { match entry { FakeFsEntry::File { content, .. } => { if path.starts_with(prefix) { result.push((path, content.clone())); } } FakeFsEntry::Dir { entries, .. } => { for (name, entry) in entries { queue.push_back((path.join(name), entry)); } } FakeFsEntry::Symlink { .. } => {} } } result } /// How many `read_dir` calls have been issued. pub fn read_dir_call_count(&self) -> usize { self.state.lock().read_dir_call_count } pub fn watched_paths(&self) -> Vec { let state = self.state.lock(); state .event_txs .iter() .filter_map(|(path, tx)| Some(path.clone()).filter(|_| !tx.is_closed())) .collect() } /// How many `metadata` calls have been issued. pub fn metadata_call_count(&self) -> usize { self.state.lock().metadata_call_count } /// How many write operations have been issued for a specific path. pub fn write_count_for_path(&self, path: impl AsRef) -> usize { let path = path.as_ref().to_path_buf(); self.state .lock() .path_write_counts .get(&path) .copied() .unwrap_or(0) } pub fn emit_fs_event(&self, path: impl Into, event: Option) { self.state.lock().emit_event(std::iter::once((path, event))); } fn simulate_random_delay(&self) -> impl futures::Future { self.executor.simulate_random_delay() } /// Returns list of all tracked trash entries. pub fn trash_entries(&self) -> Vec { self.state .lock() .trash .iter() .map(|(entry, _)| entry.clone()) .collect() } async fn remove_dir_inner( &self, path: &Path, options: RemoveOptions, ) -> Result> { self.simulate_random_delay().await; let path = normalize_path(path); let parent_path = path.parent().context("cannot remove the root")?; let base_name = path.file_name().context("cannot remove the root")?; let mut state = self.state.lock(); let parent_entry = state.entry(parent_path)?; let entry = parent_entry .dir_entries(parent_path)? .entry(base_name.to_str().unwrap().into()); let removed = match entry { btree_map::Entry::Vacant(_) => { if !options.ignore_if_not_exists { anyhow::bail!("{path:?} does not exist"); } None } btree_map::Entry::Occupied(mut entry) => { { let children = entry.get_mut().dir_entries(&path)?; if !options.recursive && !children.is_empty() { anyhow::bail!("{path:?} is not empty"); } } Some(entry.remove()) } }; state.emit_event([(path, Some(PathEventKind::Removed))]); Ok(removed) } async fn remove_file_inner( &self, path: &Path, options: RemoveOptions, ) -> Result> { self.simulate_random_delay().await; let path = normalize_path(path); let parent_path = path.parent().context("cannot remove the root")?; let base_name = path.file_name().unwrap(); let mut state = self.state.lock(); let parent_entry = state.entry(parent_path)?; let entry = parent_entry .dir_entries(parent_path)? .entry(base_name.to_str().unwrap().into()); let removed = match entry { btree_map::Entry::Vacant(_) => { if !options.ignore_if_not_exists { anyhow::bail!("{path:?} does not exist"); } None } btree_map::Entry::Occupied(mut entry) => { entry.get_mut().file_content(&path)?; Some(entry.remove()) } }; state.emit_event([(path, Some(PathEventKind::Removed))]); Ok(removed) } } #[cfg(feature = "test-support")] impl FakeFsEntry { fn is_file(&self) -> bool { matches!(self, Self::File { .. }) } fn is_symlink(&self) -> bool { matches!(self, Self::Symlink { .. }) } fn file_content(&self, path: &Path) -> Result<&Vec> { if let Self::File { content, .. } = self { Ok(content) } else { anyhow::bail!("not a file: {path:?}"); } } fn dir_entries(&mut self, path: &Path) -> Result<&mut BTreeMap> { if let Self::Dir { entries, .. } = self { Ok(entries) } else { anyhow::bail!("not a directory: {path:?}"); } } } #[cfg(feature = "test-support")] struct FakeWatcher { tx: async_channel::Sender>, original_path: PathBuf, fs_state: Arc>, prefixes: Mutex>, } #[cfg(feature = "test-support")] impl Watcher for FakeWatcher { fn add(&self, path: &Path) -> Result<()> { if path.starts_with(&self.original_path) { return Ok(()); } self.fs_state .try_lock() .unwrap() .event_txs .push((path.to_owned(), self.tx.clone())); self.prefixes.lock().push(path.to_owned()); Ok(()) } fn remove(&self, _: &Path) -> Result<()> { Ok(()) } } #[cfg(feature = "test-support")] #[derive(Debug)] struct FakeHandle { inode: u64, } #[cfg(feature = "test-support")] impl FileHandle for FakeHandle { fn current_path(&self, fs: &Arc) -> Result { let fs = fs.as_fake(); let mut state = fs.state.lock(); let Some(target) = state.moves.get(&self.inode).cloned() else { anyhow::bail!("fake fd not moved") }; if state.try_entry(&target, false).is_some() { return Ok(target); } anyhow::bail!("fake fd target not found") } } #[cfg(feature = "test-support")] #[async_trait::async_trait] impl Fs for FakeFs { async fn create_dir(&self, path: &Path) -> Result<()> { self.simulate_random_delay().await; let mut created_dirs = Vec::new(); let mut cur_path = PathBuf::new(); for component in path.components() { let should_skip = matches!(component, Component::Prefix(..) | Component::RootDir); cur_path.push(component); if should_skip { continue; } let mut state = self.state.lock(); let inode = state.get_and_increment_inode(); let mtime = state.get_and_increment_mtime(); state.write_path(&cur_path, |entry| { entry.or_insert_with(|| { created_dirs.push((cur_path.clone(), Some(PathEventKind::Created))); FakeFsEntry::Dir { inode, mtime, len: 0, entries: Default::default(), git_repo_state: None, } }); Ok(()) })? } self.state.lock().emit_event(created_dirs); Ok(()) } async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()> { self.simulate_random_delay().await; let mut state = self.state.lock(); let inode = state.get_and_increment_inode(); let mtime = state.get_and_increment_mtime(); let file = FakeFsEntry::File { inode, mtime, len: 0, content: Vec::new(), git_dir_path: None, }; let mut kind = Some(PathEventKind::Created); state.write_path(path, |entry| { match entry { btree_map::Entry::Occupied(mut e) => { if options.overwrite { kind = Some(PathEventKind::Changed); *e.get_mut() = file; } else if !options.ignore_if_exists { anyhow::bail!("path already exists: {path:?}"); } } btree_map::Entry::Vacant(e) => { e.insert(file); } } Ok(()) })?; state.emit_event([(path, kind)]); Ok(()) } async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()> { let mut state = self.state.lock(); let file = FakeFsEntry::Symlink { target }; state .write_path(path.as_ref(), move |e| match e { btree_map::Entry::Vacant(e) => { e.insert(file); Ok(()) } btree_map::Entry::Occupied(mut e) => { *e.get_mut() = file; Ok(()) } }) .unwrap(); state.emit_event([(path, Some(PathEventKind::Created))]); Ok(()) } async fn create_file_with( &self, path: &Path, mut content: Pin<&mut (dyn AsyncRead + Send)>, ) -> Result<()> { let mut bytes = Vec::new(); content.read_to_end(&mut bytes).await?; self.write_file_internal(path, bytes, true)?; Ok(()) } async fn extract_tar_file( &self, path: &Path, content: Archive>, ) -> Result<()> { let mut entries = content.entries()?; while let Some(entry) = entries.next().await { let mut entry = entry?; if entry.header().entry_type().is_file() { let path = path.join(entry.path()?.as_ref()); let mut bytes = Vec::new(); entry.read_to_end(&mut bytes).await?; self.create_dir(path.parent().unwrap()).await?; self.write_file_internal(&path, bytes, true)?; } } Ok(()) } async fn rename(&self, old_path: &Path, new_path: &Path, options: RenameOptions) -> Result<()> { self.simulate_random_delay().await; let old_path = normalize_path(old_path); let new_path = normalize_path(new_path); if options.create_parents { if let Some(parent) = new_path.parent() { self.create_dir(parent).await?; } } let mut state = self.state.lock(); let moved_entry = state.write_path(&old_path, |e| { if let btree_map::Entry::Occupied(e) = e { Ok(e.get().clone()) } else { anyhow::bail!("path does not exist: {old_path:?}") } })?; let inode = match moved_entry { FakeFsEntry::File { inode, .. } => inode, FakeFsEntry::Dir { inode, .. } => inode, _ => 0, }; state.moves.insert(inode, new_path.clone()); state.write_path(&new_path, |e| { match e { btree_map::Entry::Occupied(mut e) => { if options.overwrite { *e.get_mut() = moved_entry; } else if !options.ignore_if_exists { anyhow::bail!("path already exists: {new_path:?}"); } } btree_map::Entry::Vacant(e) => { e.insert(moved_entry); } } Ok(()) })?; state .write_path(&old_path, |e| { if let btree_map::Entry::Occupied(e) = e { Ok(e.remove()) } else { unreachable!() } }) .unwrap(); state.emit_event([ (old_path, Some(PathEventKind::Removed)), (new_path, Some(PathEventKind::Created)), ]); Ok(()) } async fn copy_file(&self, source: &Path, target: &Path, options: CopyOptions) -> Result<()> { self.simulate_random_delay().await; let source = normalize_path(source); let target = normalize_path(target); let mut state = self.state.lock(); let mtime = state.get_and_increment_mtime(); let inode = state.get_and_increment_inode(); let source_entry = state.entry(&source)?; let content = source_entry.file_content(&source)?.clone(); let mut kind = Some(PathEventKind::Created); state.write_path(&target, |e| match e { btree_map::Entry::Occupied(e) => { if options.overwrite { kind = Some(PathEventKind::Changed); Ok(Some(e.get().clone())) } else if !options.ignore_if_exists { anyhow::bail!("{target:?} already exists"); } else { Ok(None) } } btree_map::Entry::Vacant(e) => Ok(Some( e.insert(FakeFsEntry::File { inode, mtime, len: content.len() as u64, content, git_dir_path: None, }) .clone(), )), })?; state.emit_event([(target, kind)]); Ok(()) } async fn remove_dir(&self, path: &Path, options: RemoveOptions) -> Result<()> { self.remove_dir_inner(path, options).await.map(|_| ()) } async fn trash(&self, path: &Path, options: RemoveOptions) -> Result { let normalized_path = normalize_path(path); let parent_path = normalized_path.parent().context("cannot remove the root")?; let base_name = normalized_path.file_name().unwrap(); let result = if self.is_dir(path).await { self.remove_dir_inner(path, options).await? } else { self.remove_file_inner(path, options).await? }; match result { Some(fake_entry) => { let trashed_entry = TrashedEntry { id: base_name.to_str().unwrap().into(), name: base_name.to_str().unwrap().into(), original_parent: parent_path.to_path_buf(), }; let mut state = self.state.lock(); state.trash.push((trashed_entry.clone(), fake_entry)); Ok(trashed_entry) } None => anyhow::bail!("{normalized_path:?} does not exist"), } } async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()> { self.remove_file_inner(path, options).await.map(|_| ()) } async fn open_sync(&self, path: &Path) -> Result> { let bytes = self.load_internal(path).await?; Ok(Box::new(io::Cursor::new(bytes))) } async fn open_handle(&self, path: &Path) -> Result> { self.simulate_random_delay().await; let mut state = self.state.lock(); let inode = match state.entry(path)? { FakeFsEntry::File { inode, .. } => *inode, FakeFsEntry::Dir { inode, .. } => *inode, _ => unreachable!(), }; Ok(Arc::new(FakeHandle { inode })) } async fn load(&self, path: &Path) -> Result { let content = self.load_internal(path).await?; Ok(String::from_utf8(content)?) } async fn load_bytes(&self, path: &Path) -> Result> { self.load_internal(path).await } async fn atomic_write(&self, path: PathBuf, data: String) -> Result<()> { self.simulate_random_delay().await; let path = normalize_path(path.as_path()); if let Some(path) = path.parent() { self.create_dir(path).await?; } self.write_file_internal(path, data.into_bytes(), true)?; Ok(()) } async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> { self.simulate_random_delay().await; let path = normalize_path(path); let content = text::chunks_with_line_ending(text, line_ending).collect::(); if let Some(path) = path.parent() { self.create_dir(path).await?; } self.write_file_internal(path, content.into_bytes(), false)?; Ok(()) } async fn write(&self, path: &Path, content: &[u8]) -> Result<()> { self.simulate_random_delay().await; let path = normalize_path(path); if let Some(path) = path.parent() { self.create_dir(path).await?; } self.write_file_internal(path, content.to_vec(), false)?; Ok(()) } async fn canonicalize(&self, path: &Path) -> Result { let path = normalize_path(path); self.simulate_random_delay().await; let state = self.state.lock(); let canonical_path = state .canonicalize(&path, true) .with_context(|| format!("path does not exist: {path:?}"))?; Ok(canonical_path) } async fn is_file(&self, path: &Path) -> bool { let path = normalize_path(path); self.simulate_random_delay().await; let mut state = self.state.lock(); if let Some((entry, _)) = state.try_entry(&path, true) { entry.is_file() } else { false } } async fn is_dir(&self, path: &Path) -> bool { self.metadata(path) .await .is_ok_and(|metadata| metadata.is_some_and(|metadata| metadata.is_dir)) } async fn metadata(&self, path: &Path) -> Result> { self.simulate_random_delay().await; let path = normalize_path(path); let mut state = self.state.lock(); state.metadata_call_count += 1; if let Some((mut entry, _)) = state.try_entry(&path, false) { let is_symlink = entry.is_symlink(); if is_symlink { if let Some(e) = state.try_entry(&path, true).map(|e| e.0) { entry = e; } else { return Ok(None); } } Ok(Some(match &*entry { FakeFsEntry::File { inode, mtime, len, .. } => Metadata { inode: *inode, mtime: *mtime, len: *len, is_dir: false, is_symlink, is_fifo: false, is_executable: false, }, FakeFsEntry::Dir { inode, mtime, len, .. } => Metadata { inode: *inode, mtime: *mtime, len: *len, is_dir: true, is_symlink, is_fifo: false, is_executable: false, }, FakeFsEntry::Symlink { .. } => unreachable!(), })) } else { Ok(None) } } async fn read_link(&self, path: &Path) -> Result { self.simulate_random_delay().await; let path = normalize_path(path); let mut state = self.state.lock(); let (entry, _) = state .try_entry(&path, false) .with_context(|| format!("path does not exist: {path:?}"))?; if let FakeFsEntry::Symlink { target } = entry { Ok(target.clone()) } else { anyhow::bail!("not a symlink: {path:?}") } } async fn read_dir( &self, path: &Path, ) -> Result>>>> { self.simulate_random_delay().await; let path = normalize_path(path); let mut state = self.state.lock(); state.read_dir_call_count += 1; let entry = state.entry(&path)?; let children = entry.dir_entries(&path)?; let paths = children .keys() .map(|file_name| Ok(path.join(file_name))) .collect::>(); Ok(Box::pin(futures::stream::iter(paths))) } async fn watch( &self, path: &Path, _: Duration, ) -> ( Pin>>>, Arc, ) { self.simulate_random_delay().await; let (tx, rx) = async_channel::unbounded(); let path = path.to_path_buf(); self.state.lock().event_txs.push((path.clone(), tx.clone())); let executor = self.executor.clone(); let watcher = Arc::new(FakeWatcher { tx, original_path: path.to_owned(), fs_state: self.state.clone(), prefixes: Mutex::new(vec![path]), }); ( Box::pin(futures::StreamExt::filter(rx, { let watcher = watcher.clone(); move |events| { let result = events.iter().any(|evt_path| { watcher .prefixes .lock() .iter() .any(|prefix| evt_path.path.starts_with(prefix)) }); let executor = executor.clone(); async move { executor.simulate_random_delay().await; result } } })), watcher, ) } fn open_repo( &self, abs_dot_git: &Path, _system_git_binary: Option<&Path>, ) -> Result> { self.with_git_state_and_paths( abs_dot_git, false, |_, repository_dir_path, common_dir_path| { Arc::new(fake_git_repo::FakeGitRepository { fs: self.this.upgrade().unwrap(), executor: self.executor.clone(), dot_git_path: abs_dot_git.to_path_buf(), repository_dir_path: repository_dir_path.to_owned(), common_dir_path: common_dir_path.to_owned(), checkpoints: Arc::default(), is_trusted: Arc::default(), }) as _ }, ) } async fn git_init( &self, abs_work_directory_path: &Path, _fallback_branch_name: String, ) -> Result<()> { self.create_dir(&abs_work_directory_path.join(".git")).await } async fn git_clone(&self, _abs_work_directory: &Path, _repo_url: &str) -> Result<()> { anyhow::bail!("Git clone is not supported in fake Fs") } async fn git_config(&self, _abs_work_directory: &Path, _args: Vec) -> Result { anyhow::bail!("Git config is not supported in fake Fs") } fn is_fake(&self) -> bool { true } async fn is_case_sensitive(&self) -> bool { true } fn subscribe_to_jobs(&self) -> JobEventReceiver { let (sender, receiver) = futures::channel::mpsc::unbounded(); self.state.lock().job_event_subscribers.lock().push(sender); receiver } async fn restore(&self, trashed_entry: TrashedEntry) -> Result { let mut state = self.state.lock(); let Some((trashed_entry, fake_entry)) = state .trash .iter() .find(|(entry, _)| *entry == trashed_entry) .cloned() else { return Err(TrashRestoreError::NotFound { path: PathBuf::from(trashed_entry.id), }); }; let path = trashed_entry .original_parent .join(trashed_entry.name.clone()); let result = state.write_path(&path, |entry| match entry { btree_map::Entry::Vacant(entry) => { entry.insert(fake_entry); Ok(()) } btree_map::Entry::Occupied(_) => { anyhow::bail!("Failed to restore {:?}", path); } }); match result { Ok(_) => { state.trash.retain(|(entry, _)| *entry != trashed_entry); state.emit_event([(path.clone(), Some(PathEventKind::Created))]); Ok(path) } Err(_) => { // For now we'll just assume that this failed because it was a // collision error, which I think that, for the time being, is // the only case where this could fail? Err(TrashRestoreError::Collision { path }) } } } #[cfg(feature = "test-support")] fn as_fake(&self) -> Arc { self.this.upgrade().unwrap() } } pub async fn copy_recursive<'a>( fs: &'a dyn Fs, source: &'a Path, target: &'a Path, options: CopyOptions, ) -> Result<()> { for (item, is_dir) in read_dir_items(fs, source).await? { let Ok(item_relative_path) = item.strip_prefix(source) else { continue; }; let target_item = if item_relative_path == Path::new("") { target.to_path_buf() } else { target.join(item_relative_path) }; if is_dir { if !options.overwrite && fs.metadata(&target_item).await.is_ok_and(|m| m.is_some()) { if options.ignore_if_exists { continue; } else { anyhow::bail!("{target_item:?} already exists"); } } let _ = fs .remove_dir( &target_item, RemoveOptions { recursive: true, ignore_if_not_exists: true, }, ) .await; fs.create_dir(&target_item).await?; } else { fs.copy_file(&item, &target_item, options).await?; } } Ok(()) } /// Recursively reads all of the paths in the given directory. /// /// Returns a vector of tuples of (path, is_dir). pub async fn read_dir_items<'a>(fs: &'a dyn Fs, source: &'a Path) -> Result> { let mut items = Vec::new(); read_recursive(fs, source, &mut items).await?; Ok(items) } fn read_recursive<'a>( fs: &'a dyn Fs, source: &'a Path, output: &'a mut Vec<(PathBuf, bool)>, ) -> BoxFuture<'a, Result<()>> { use futures::future::FutureExt; async move { let metadata = fs .metadata(source) .await? .with_context(|| format!("path does not exist: {source:?}"))?; if metadata.is_dir { output.push((source.to_path_buf(), true)); let mut children = fs.read_dir(source).await?; while let Some(child_path) = children.next().await { if let Ok(child_path) = child_path { read_recursive(fs, &child_path, output).await?; } } } else { output.push((source.to_path_buf(), false)); } Ok(()) } .boxed() } // todo(windows) // can we get file id not open the file twice? // https://github.com/rust-lang/rust/issues/63010 #[cfg(target_os = "windows")] async fn file_id(path: impl AsRef) -> Result { use std::os::windows::io::AsRawHandle; use smol::fs::windows::OpenOptionsExt; use windows::Win32::{ Foundation::HANDLE, Storage::FileSystem::{ BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, GetFileInformationByHandle, }, }; let file = smol::fs::OpenOptions::new() .read(true) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) .open(path) .await?; let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; // https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfileinformationbyhandle // This function supports Windows XP+ smol::unblock(move || { unsafe { GetFileInformationByHandle(HANDLE(file.as_raw_handle() as _), &mut info)? }; Ok(((info.nFileIndexHigh as u64) << 32) | (info.nFileIndexLow as u64)) }) .await } #[cfg(target_os = "windows")] fn atomic_replace>( replaced_file: P, replacement_file: P, ) -> windows::core::Result<()> { use windows::{ Win32::Storage::FileSystem::{REPLACE_FILE_FLAGS, ReplaceFileW}, core::HSTRING, }; // If the file does not exist, create it. let _ = std::fs::File::create_new(replaced_file.as_ref()); unsafe { ReplaceFileW( &HSTRING::from(replaced_file.as_ref().to_string_lossy().into_owned()), &HSTRING::from(replacement_file.as_ref().to_string_lossy().into_owned()), None, REPLACE_FILE_FLAGS::default(), None, None, ) } }