logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree with a 3-file patch applied (no upstream history). Patch (crates/gpui_linux/src/linux/wayland/): - serial.rs: add SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; add latest_serial_of() - window.rs: activate() uses keyboard-enter serial (Mutter focus gate) Mutter honors window activation only when the token carries the keyboard- focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial. See docs/tray-window-focus-wayland.md in logiguard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
62
crates/fs/Cargo.toml
Normal file
62
crates/fs/Cargo.toml
Normal file
@@ -0,0 +1,62 @@
|
||||
[package]
|
||||
name = "fs"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/fs.rs"
|
||||
test = false
|
||||
|
||||
[[test]]
|
||||
name = "integration"
|
||||
required-features = ["test-support"]
|
||||
path = "tests/integration/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-channel.workspace = true
|
||||
async-tar.workspace = true
|
||||
async-trait.workspace = true
|
||||
collections.workspace = true
|
||||
futures.workspace = true
|
||||
git.workspace = true
|
||||
gpui.workspace = true
|
||||
ignore.workspace = true
|
||||
libc.workspace = true
|
||||
log.workspace = true
|
||||
parking_lot.workspace = true
|
||||
paths.workspace = true
|
||||
rope.workspace = true
|
||||
proto.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
smol.workspace = true
|
||||
telemetry.workspace = true
|
||||
tempfile.workspace = true
|
||||
text.workspace = true
|
||||
time.workspace = true
|
||||
util.workspace = true
|
||||
is_executable = "1.0.5"
|
||||
notify = "8.2.0"
|
||||
trash = { git = "https://github.com/zed-industries/trash-rs", rev = "3bf27effd4eb8699f2e484d3326b852fe3e53af7" }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows.workspace = true
|
||||
dunce.workspace = true
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
|
||||
ashpd.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
fs = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
git = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[features]
|
||||
test-support = ["gpui/test-support", "git/test-support", "util/test-support"]
|
||||
1
crates/fs/LICENSE-GPL
Symbolic link
1
crates/fs/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
1548
crates/fs/src/fake_git_repo.rs
Normal file
1548
crates/fs/src/fake_git_repo.rs
Normal file
File diff suppressed because it is too large
Load Diff
3470
crates/fs/src/fs.rs
Normal file
3470
crates/fs/src/fs.rs
Normal file
File diff suppressed because it is too large
Load Diff
565
crates/fs/src/fs_watcher.rs
Normal file
565
crates/fs/src/fs_watcher.rs
Normal file
@@ -0,0 +1,565 @@
|
||||
use notify::{Event, EventKind};
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
ops::DerefMut,
|
||||
path::Path,
|
||||
sync::{Arc, LazyLock, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
use util::{ResultExt, paths::SanitizedPath};
|
||||
|
||||
use crate::{PathEvent, PathEventKind, Watcher};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum WatcherMode {
|
||||
#[default]
|
||||
Native,
|
||||
Poll,
|
||||
}
|
||||
|
||||
pub struct FsWatcher {
|
||||
tx: async_channel::Sender<()>,
|
||||
pending_path_events: Arc<Mutex<Vec<PathEvent>>>,
|
||||
registrations: Mutex<BTreeMap<Arc<std::path::Path>, WatcherRegistrationId>>,
|
||||
mode: WatcherMode,
|
||||
}
|
||||
|
||||
impl FsWatcher {
|
||||
pub fn new(
|
||||
tx: async_channel::Sender<()>,
|
||||
pending_path_events: Arc<Mutex<Vec<PathEvent>>>,
|
||||
mode: WatcherMode,
|
||||
) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
pending_path_events,
|
||||
registrations: Default::default(),
|
||||
mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FsWatcher {
|
||||
fn drop(&mut self) {
|
||||
let mut registrations = BTreeMap::new();
|
||||
{
|
||||
let old = &mut self.registrations.lock();
|
||||
std::mem::swap(old.deref_mut(), &mut registrations);
|
||||
}
|
||||
|
||||
let global_watcher = global_watcher();
|
||||
for (_, registration) in registrations {
|
||||
global_watcher.remove(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Watcher for FsWatcher {
|
||||
fn add(&self, path: &std::path::Path) -> anyhow::Result<()> {
|
||||
log::trace!("watcher add: {path:?}");
|
||||
let tx = self.tx.clone();
|
||||
let pending_path_events = self.pending_path_events.clone();
|
||||
|
||||
if (self.mode == WatcherMode::Poll || cfg!(any(target_os = "windows", target_os = "macos")))
|
||||
&& let Some((watched_path, _)) = self
|
||||
.registrations
|
||||
.lock()
|
||||
.range::<std::path::Path, _>((
|
||||
std::ops::Bound::Unbounded,
|
||||
std::ops::Bound::Included(path),
|
||||
))
|
||||
.next_back()
|
||||
&& path.starts_with(watched_path.as_ref())
|
||||
{
|
||||
log::trace!(
|
||||
"path to watch is covered by existing registration: {path:?}, {watched_path:?}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.registrations.lock().contains_key(path) {
|
||||
log::trace!("path to watch is already watched: {path:?}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let root_path = SanitizedPath::new_arc(path);
|
||||
let path: Arc<std::path::Path> = path.into();
|
||||
|
||||
let registration_path = path.clone();
|
||||
let registration_id =
|
||||
global_watcher().add(path.clone(), self.mode, move |event: ¬ify::Event| {
|
||||
log::trace!("watcher received event: {event:?}");
|
||||
push_notify_event(&tx, &pending_path_events, &root_path, path.as_ref(), event);
|
||||
})?;
|
||||
|
||||
self.registrations
|
||||
.lock()
|
||||
.insert(registration_path, registration_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove(&self, path: &std::path::Path) -> anyhow::Result<()> {
|
||||
log::trace!("remove watched path: {path:?}");
|
||||
let Some(registration) = self.registrations.lock().remove(path) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
global_watcher().remove(registration);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_path_events(
|
||||
tx: &smol::channel::Sender<()>,
|
||||
pending_path_events: &Arc<Mutex<Vec<PathEvent>>>,
|
||||
mut path_events: Vec<PathEvent>,
|
||||
) {
|
||||
if path_events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
path_events.sort();
|
||||
let mut pending_paths = pending_path_events.lock();
|
||||
if pending_paths.is_empty() {
|
||||
tx.try_send(()).ok();
|
||||
}
|
||||
coalesce_pending_rescans(&mut pending_paths, &mut path_events);
|
||||
util::extend_sorted(&mut *pending_paths, path_events, usize::MAX, |a, b| {
|
||||
a.path.cmp(&b.path)
|
||||
});
|
||||
}
|
||||
|
||||
fn push_notify_event(
|
||||
tx: &smol::channel::Sender<()>,
|
||||
pending_path_events: &Arc<Mutex<Vec<PathEvent>>>,
|
||||
root_path: &SanitizedPath,
|
||||
watched_root: &Path,
|
||||
event: ¬ify::Event,
|
||||
) {
|
||||
let kind = match event.kind {
|
||||
EventKind::Create(_) => Some(PathEventKind::Created),
|
||||
EventKind::Modify(_) => Some(PathEventKind::Changed),
|
||||
EventKind::Remove(_) => Some(PathEventKind::Removed),
|
||||
_ => None,
|
||||
};
|
||||
let mut path_events = event
|
||||
.paths
|
||||
.iter()
|
||||
.filter_map(|event_path| {
|
||||
let event_path = SanitizedPath::new(event_path);
|
||||
event_path.starts_with(root_path).then(|| PathEvent {
|
||||
path: event_path.as_path().to_path_buf(),
|
||||
kind,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if event.need_rescan() {
|
||||
log::warn!("filesystem watcher lost sync for {watched_root:?}; scheduling rescan");
|
||||
path_events.retain(|path_event| path_event.path != watched_root);
|
||||
path_events.push(PathEvent {
|
||||
path: watched_root.to_path_buf(),
|
||||
kind: Some(PathEventKind::Rescan),
|
||||
});
|
||||
}
|
||||
|
||||
enqueue_path_events(tx, pending_path_events, path_events);
|
||||
}
|
||||
|
||||
fn coalesce_pending_rescans(pending_paths: &mut Vec<PathEvent>, path_events: &mut Vec<PathEvent>) {
|
||||
if !path_events
|
||||
.iter()
|
||||
.any(|event| event.kind == Some(PathEventKind::Rescan))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let mut new_rescan_paths: Vec<std::path::PathBuf> = path_events
|
||||
.iter()
|
||||
.filter(|e| e.kind == Some(PathEventKind::Rescan))
|
||||
.map(|e| e.path.clone())
|
||||
.collect();
|
||||
new_rescan_paths.sort_unstable();
|
||||
|
||||
let mut deduped_rescans: Vec<std::path::PathBuf> = Vec::with_capacity(new_rescan_paths.len());
|
||||
for path in new_rescan_paths {
|
||||
if deduped_rescans
|
||||
.iter()
|
||||
.any(|ancestor| path != *ancestor && path.starts_with(ancestor))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
deduped_rescans.push(path);
|
||||
}
|
||||
|
||||
deduped_rescans.retain(|new_path| {
|
||||
!pending_paths
|
||||
.iter()
|
||||
.any(|pending| is_covered_rescan(pending.kind, new_path, &pending.path))
|
||||
});
|
||||
|
||||
if !deduped_rescans.is_empty() {
|
||||
pending_paths.retain(|pending| {
|
||||
!deduped_rescans.iter().any(|rescan_path| {
|
||||
pending.path == *rescan_path
|
||||
|| is_covered_rescan(pending.kind, &pending.path, rescan_path)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
path_events.retain(|event| {
|
||||
event.kind != Some(PathEventKind::Rescan) || deduped_rescans.contains(&event.path)
|
||||
});
|
||||
}
|
||||
|
||||
fn is_covered_rescan(kind: Option<PathEventKind>, path: &Path, ancestor: &Path) -> bool {
|
||||
kind == Some(PathEventKind::Rescan) && path != ancestor && path.starts_with(ancestor)
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct WatcherRegistrationId(u32);
|
||||
|
||||
struct WatcherRegistrationState {
|
||||
callback: Arc<dyn Fn(¬ify::Event) + Send + Sync>,
|
||||
path: Arc<std::path::Path>,
|
||||
mode: WatcherMode,
|
||||
}
|
||||
|
||||
struct WatcherState {
|
||||
watchers: HashMap<WatcherRegistrationId, WatcherRegistrationState>,
|
||||
native_path_registrations: HashMap<Arc<std::path::Path>, u32>,
|
||||
poll_path_registrations: HashMap<Arc<std::path::Path>, u32>,
|
||||
last_registration: WatcherRegistrationId,
|
||||
}
|
||||
|
||||
impl WatcherState {
|
||||
fn path_registrations(&mut self, mode: WatcherMode) -> &mut HashMap<Arc<std::path::Path>, u32> {
|
||||
match mode {
|
||||
WatcherMode::Native => &mut self.native_path_registrations,
|
||||
WatcherMode::Poll => &mut self.poll_path_registrations,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GlobalWatcher {
|
||||
state: Mutex<WatcherState>,
|
||||
|
||||
// DANGER: never keep state lock while holding watcher lock
|
||||
// two mutexes because calling watcher.add triggers watcher.event, which needs watchers.
|
||||
native_watcher: Mutex<Option<notify::RecommendedWatcher>>,
|
||||
poll_watcher: Mutex<Option<notify::PollWatcher>>,
|
||||
}
|
||||
|
||||
impl GlobalWatcher {
|
||||
#[must_use]
|
||||
fn add(
|
||||
&self,
|
||||
path: Arc<std::path::Path>,
|
||||
mode: WatcherMode,
|
||||
cb: impl Fn(¬ify::Event) + Send + Sync + 'static,
|
||||
) -> anyhow::Result<WatcherRegistrationId> {
|
||||
let mut state = self.state.lock();
|
||||
let registrations_for_mode = state.path_registrations(mode);
|
||||
let path_already_covered =
|
||||
path_already_covered(path.as_ref(), registrations_for_mode, mode);
|
||||
|
||||
if !path_already_covered && !registrations_for_mode.contains_key(&path) {
|
||||
drop(state);
|
||||
self.watch(&path, mode)?;
|
||||
state = self.state.lock();
|
||||
}
|
||||
|
||||
let id = state.last_registration;
|
||||
state.last_registration = WatcherRegistrationId(id.0 + 1);
|
||||
|
||||
let registration_state = WatcherRegistrationState {
|
||||
callback: Arc::new(cb),
|
||||
path: path.clone(),
|
||||
mode,
|
||||
};
|
||||
state.watchers.insert(id, registration_state);
|
||||
*state.path_registrations(mode).entry(path).or_insert(0) += 1;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: WatcherRegistrationId) {
|
||||
let mut state = self.state.lock();
|
||||
let Some(registration_state) = state.watchers.remove(&id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let path_registrations = state.path_registrations(registration_state.mode);
|
||||
let Some(count) = path_registrations.get_mut(®istration_state.path) else {
|
||||
return;
|
||||
};
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
path_registrations.remove(®istration_state.path);
|
||||
let path_still_covered = path_already_covered(
|
||||
registration_state.path.as_ref(),
|
||||
path_registrations,
|
||||
registration_state.mode,
|
||||
);
|
||||
|
||||
if !path_still_covered {
|
||||
drop(state);
|
||||
self.unwatch(®istration_state.path, registration_state.mode)
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn watch(&self, path: &Path, mode: WatcherMode) -> anyhow::Result<()> {
|
||||
use notify::Watcher;
|
||||
|
||||
match mode {
|
||||
WatcherMode::Native => {
|
||||
self.ensure_native_watcher()?;
|
||||
self.native_watcher
|
||||
.lock()
|
||||
.as_mut()
|
||||
.expect("native watcher initialized")
|
||||
.watch(
|
||||
path,
|
||||
if cfg!(any(target_os = "windows", target_os = "macos")) {
|
||||
notify::RecursiveMode::Recursive
|
||||
} else {
|
||||
notify::RecursiveMode::NonRecursive
|
||||
},
|
||||
)?;
|
||||
}
|
||||
WatcherMode::Poll => {
|
||||
self.ensure_poll_watcher()?;
|
||||
self.poll_watcher
|
||||
.lock()
|
||||
.as_mut()
|
||||
.expect("poll watcher initialized")
|
||||
.watch(path, notify::RecursiveMode::Recursive)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unwatch(&self, path: &Path, mode: WatcherMode) -> anyhow::Result<()> {
|
||||
use notify::Watcher;
|
||||
|
||||
match mode {
|
||||
WatcherMode::Native => {
|
||||
if let Some(watcher) = self.native_watcher.lock().as_mut() {
|
||||
watcher.unwatch(path)?;
|
||||
}
|
||||
}
|
||||
WatcherMode::Poll => {
|
||||
if let Some(watcher) = self.poll_watcher.lock().as_mut() {
|
||||
watcher.unwatch(path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_native_watcher(&self) -> anyhow::Result<()> {
|
||||
if self.native_watcher.lock().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let watcher = notify::recommended_watcher(handle_native_event)?;
|
||||
*self.native_watcher.lock() = Some(watcher);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_poll_watcher(&self) -> anyhow::Result<()> {
|
||||
if self.poll_watcher.lock().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config = notify::Config::default().with_poll_interval(*POLL_INTERVAL);
|
||||
let watcher = notify::PollWatcher::new(handle_poll_event, config)?;
|
||||
*self.poll_watcher.lock() = Some(watcher);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn path_already_covered(
|
||||
path: &Path,
|
||||
path_registrations: &HashMap<Arc<std::path::Path>, u32>,
|
||||
mode: WatcherMode,
|
||||
) -> bool {
|
||||
(mode == WatcherMode::Poll || cfg!(any(target_os = "windows", target_os = "macos")))
|
||||
&& path_registrations
|
||||
.keys()
|
||||
.any(|existing| path.starts_with(existing.as_ref()) && path != existing.as_ref())
|
||||
}
|
||||
|
||||
static POLL_INTERVAL: LazyLock<Duration> = LazyLock::new(|| {
|
||||
let poll_ms: u64 = std::env::var("ZED_FILE_WATCHER_POLL_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(2000)
|
||||
.clamp(500, 30000);
|
||||
Duration::from_millis(poll_ms)
|
||||
});
|
||||
|
||||
pub fn poll_interval() -> Duration {
|
||||
*POLL_INTERVAL
|
||||
}
|
||||
|
||||
static FS_WATCHER_INSTANCE: OnceLock<GlobalWatcher> = OnceLock::new();
|
||||
|
||||
fn global_watcher() -> &'static GlobalWatcher {
|
||||
FS_WATCHER_INSTANCE.get_or_init(|| GlobalWatcher {
|
||||
state: Mutex::new(WatcherState {
|
||||
watchers: Default::default(),
|
||||
native_path_registrations: Default::default(),
|
||||
poll_path_registrations: Default::default(),
|
||||
last_registration: Default::default(),
|
||||
}),
|
||||
native_watcher: Mutex::new(None),
|
||||
poll_watcher: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_native_event(event: Result<notify::Event, notify::Error>) {
|
||||
handle_event(WatcherMode::Native, event);
|
||||
}
|
||||
|
||||
fn handle_poll_event(event: Result<notify::Event, notify::Error>) {
|
||||
handle_event(WatcherMode::Poll, event);
|
||||
}
|
||||
|
||||
fn handle_event(mode: WatcherMode, event: Result<notify::Event, notify::Error>) {
|
||||
if matches!(
|
||||
event,
|
||||
Ok(Event {
|
||||
kind: EventKind::Access(_),
|
||||
..
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
log::trace!("global handle event for {mode:?}: {event:?}");
|
||||
|
||||
let callbacks = {
|
||||
let state = global_watcher().state.lock();
|
||||
state
|
||||
.watchers
|
||||
.values()
|
||||
.filter(|registration| registration.mode == mode)
|
||||
.map(|registration| registration.callback.clone())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
match event {
|
||||
Ok(event) => {
|
||||
for callback in callbacks {
|
||||
callback(&event);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("watcher error for {mode:?}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn rescan(path: &str) -> PathEvent {
|
||||
PathEvent {
|
||||
path: PathBuf::from(path),
|
||||
kind: Some(PathEventKind::Rescan),
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(path: &str) -> PathEvent {
|
||||
PathEvent {
|
||||
path: PathBuf::from(path),
|
||||
kind: Some(PathEventKind::Changed),
|
||||
}
|
||||
}
|
||||
|
||||
struct TestCase {
|
||||
name: &'static str,
|
||||
pending_paths: Vec<PathEvent>,
|
||||
path_events: Vec<PathEvent>,
|
||||
expected_pending_paths: Vec<PathEvent>,
|
||||
expected_path_events: Vec<PathEvent>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_coalesce_pending_rescans() {
|
||||
let test_cases = [
|
||||
TestCase {
|
||||
name: "coalesces descendant rescans under pending ancestor",
|
||||
pending_paths: vec![rescan("/root")],
|
||||
path_events: vec![rescan("/root/child"), rescan("/root/child/grandchild")],
|
||||
expected_pending_paths: vec![rescan("/root")],
|
||||
expected_path_events: vec![],
|
||||
},
|
||||
TestCase {
|
||||
name: "new ancestor rescan replaces pending descendant rescans",
|
||||
pending_paths: vec![
|
||||
changed("/other"),
|
||||
rescan("/root/child"),
|
||||
rescan("/root/child/grandchild"),
|
||||
],
|
||||
path_events: vec![rescan("/root")],
|
||||
expected_pending_paths: vec![changed("/other")],
|
||||
expected_path_events: vec![rescan("/root")],
|
||||
},
|
||||
TestCase {
|
||||
name: "same path rescan replaces pending non-rescan event",
|
||||
pending_paths: vec![changed("/root")],
|
||||
path_events: vec![rescan("/root")],
|
||||
expected_pending_paths: vec![],
|
||||
expected_path_events: vec![rescan("/root")],
|
||||
},
|
||||
TestCase {
|
||||
name: "unrelated rescans are preserved",
|
||||
pending_paths: vec![rescan("/root-a")],
|
||||
path_events: vec![rescan("/root-b")],
|
||||
expected_pending_paths: vec![rescan("/root-a")],
|
||||
expected_path_events: vec![rescan("/root-b")],
|
||||
},
|
||||
TestCase {
|
||||
name: "batch ancestor rescan replaces descendant rescan",
|
||||
pending_paths: vec![],
|
||||
path_events: vec![rescan("/root/child"), rescan("/root")],
|
||||
expected_pending_paths: vec![],
|
||||
expected_path_events: vec![rescan("/root")],
|
||||
},
|
||||
];
|
||||
|
||||
for test_case in test_cases {
|
||||
let mut pending_paths = test_case.pending_paths;
|
||||
let mut path_events = test_case.path_events;
|
||||
|
||||
coalesce_pending_rescans(&mut pending_paths, &mut path_events);
|
||||
|
||||
assert_eq!(
|
||||
pending_paths, test_case.expected_pending_paths,
|
||||
"pending_paths mismatch for case: {}",
|
||||
test_case.name
|
||||
);
|
||||
assert_eq!(
|
||||
path_events, test_case.expected_path_events,
|
||||
"path_events mismatch for case: {}",
|
||||
test_case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn global<T>(f: impl FnOnce(&GlobalWatcher) -> T) -> anyhow::Result<T> {
|
||||
let global_watcher = global_watcher();
|
||||
global_watcher.ensure_native_watcher()?;
|
||||
Ok(f(global_watcher))
|
||||
}
|
||||
196
crates/fs/tests/integration/fake_git_repo.rs
Normal file
196
crates/fs/tests/integration/fake_git_repo.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use fs::{FakeFs, Fs};
|
||||
use gpui::{BackgroundExecutor, TestAppContext};
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
use util::path;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) {
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree("/project", json!({".git": {}, "file.txt": "content"}))
|
||||
.await;
|
||||
let repo = fs
|
||||
.open_repo(Path::new("/project/.git"), None)
|
||||
.expect("should open fake repo");
|
||||
|
||||
// Initially only the main worktree exists
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 1);
|
||||
assert_eq!(worktrees[0].path, PathBuf::from("/project"));
|
||||
|
||||
fs.create_dir("/my-worktrees".as_ref()).await.unwrap();
|
||||
let worktrees_dir = Path::new("/my-worktrees");
|
||||
|
||||
// Create a worktree
|
||||
let worktree_1_dir = worktrees_dir.join("feature-branch");
|
||||
repo.create_worktree(
|
||||
git::repository::CreateWorktreeTarget::NewBranch {
|
||||
branch_name: "feature-branch".to_string(),
|
||||
base_sha: Some("abc123".to_string()),
|
||||
},
|
||||
worktree_1_dir.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// List worktrees — should have main + one created
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 2);
|
||||
assert_eq!(worktrees[0].path, PathBuf::from("/project"));
|
||||
assert_eq!(worktrees[1].path, worktree_1_dir);
|
||||
assert_eq!(
|
||||
worktrees[1].ref_name,
|
||||
Some("refs/heads/feature-branch".into())
|
||||
);
|
||||
assert_eq!(worktrees[1].sha.as_ref(), "abc123");
|
||||
|
||||
// Directory should exist in FakeFs after create
|
||||
assert!(fs.is_dir(&worktrees_dir.join("feature-branch")).await);
|
||||
|
||||
// Create a second worktree (without explicit commit)
|
||||
let worktree_2_dir = worktrees_dir.join("bugfix-branch");
|
||||
repo.create_worktree(
|
||||
git::repository::CreateWorktreeTarget::NewBranch {
|
||||
branch_name: "bugfix-branch".to_string(),
|
||||
base_sha: None,
|
||||
},
|
||||
worktree_2_dir.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 3);
|
||||
assert!(fs.is_dir(&worktree_2_dir).await);
|
||||
|
||||
// Rename the first worktree
|
||||
repo.rename_worktree(worktree_1_dir, worktrees_dir.join("renamed-branch"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 3);
|
||||
assert!(
|
||||
worktrees
|
||||
.iter()
|
||||
.any(|w| w.path == worktrees_dir.join("renamed-branch")),
|
||||
);
|
||||
assert!(
|
||||
worktrees
|
||||
.iter()
|
||||
.all(|w| w.path != worktrees_dir.join("feature-branch")),
|
||||
);
|
||||
|
||||
// Directory should be moved in FakeFs after rename
|
||||
assert!(!fs.is_dir(&worktrees_dir.join("feature-branch")).await);
|
||||
assert!(fs.is_dir(&worktrees_dir.join("renamed-branch")).await);
|
||||
|
||||
// Rename a nonexistent worktree should fail
|
||||
let result = repo
|
||||
.rename_worktree(PathBuf::from("/nonexistent"), PathBuf::from("/somewhere"))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
|
||||
// Remove a worktree
|
||||
repo.remove_worktree(worktrees_dir.join("renamed-branch"), false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 2);
|
||||
assert_eq!(worktrees[0].path, PathBuf::from("/project"));
|
||||
assert_eq!(worktrees[1].path, worktree_2_dir);
|
||||
|
||||
// Directory should be removed from FakeFs after remove
|
||||
assert!(!fs.is_dir(&worktrees_dir.join("renamed-branch")).await);
|
||||
|
||||
// Remove a nonexistent worktree should fail
|
||||
let result = repo
|
||||
.remove_worktree(PathBuf::from("/nonexistent"), false)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
|
||||
// Remove the last worktree
|
||||
repo.remove_worktree(worktree_2_dir.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 1);
|
||||
assert_eq!(worktrees[0].path, PathBuf::from("/project"));
|
||||
assert!(!fs.is_dir(&worktree_2_dir).await);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_checkpoints(executor: BackgroundExecutor) {
|
||||
let fs = FakeFs::new(executor);
|
||||
fs.insert_tree(
|
||||
path!("/"),
|
||||
json!({
|
||||
"bar": {
|
||||
"baz": "qux"
|
||||
},
|
||||
"foo": {
|
||||
".git": {},
|
||||
"a": "lorem",
|
||||
"b": "ipsum",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
fs.with_git_state(Path::new("/foo/.git"), true, |_git| {})
|
||||
.unwrap();
|
||||
let repository = fs
|
||||
.open_repo(Path::new("/foo/.git"), Some("git".as_ref()))
|
||||
.unwrap();
|
||||
|
||||
let checkpoint_1 = repository.checkpoint().await.unwrap();
|
||||
fs.write(Path::new("/foo/b"), b"IPSUM").await.unwrap();
|
||||
fs.write(Path::new("/foo/c"), b"dolor").await.unwrap();
|
||||
let checkpoint_2 = repository.checkpoint().await.unwrap();
|
||||
let checkpoint_3 = repository.checkpoint().await.unwrap();
|
||||
|
||||
assert!(
|
||||
repository
|
||||
.compare_checkpoints(checkpoint_2.clone(), checkpoint_3.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!repository
|
||||
.compare_checkpoints(checkpoint_1.clone(), checkpoint_2.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
repository
|
||||
.restore_checkpoint(checkpoint_1.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fs.files_with_contents(Path::new("")),
|
||||
[
|
||||
(Path::new(path!("/bar/baz")).into(), b"qux".into()),
|
||||
(Path::new(path!("/foo/a")).into(), b"lorem".into()),
|
||||
(Path::new(path!("/foo/b")).into(), b"ipsum".into())
|
||||
]
|
||||
);
|
||||
|
||||
// diff_checkpoints: identical checkpoints produce empty diff
|
||||
let diff = repository
|
||||
.diff_checkpoints(checkpoint_2.clone(), checkpoint_3.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
diff.is_empty(),
|
||||
"identical checkpoints should produce empty diff"
|
||||
);
|
||||
|
||||
// diff_checkpoints: different checkpoints produce non-empty diff
|
||||
let diff = repository
|
||||
.diff_checkpoints(checkpoint_1.clone(), checkpoint_2.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(diff.contains("b"), "diff should mention changed file 'b'");
|
||||
assert!(diff.contains("c"), "diff should mention added file 'c'");
|
||||
}
|
||||
914
crates/fs/tests/integration/fs.rs
Normal file
914
crates/fs/tests/integration/fs.rs
Normal file
@@ -0,0 +1,914 @@
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
ffi::OsString,
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use futures::{FutureExt, StreamExt};
|
||||
|
||||
use fs::*;
|
||||
use gpui::{BackgroundExecutor, TestAppContext};
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use util::path;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_fake_fs(executor: BackgroundExecutor) {
|
||||
let fs = FakeFs::new(executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"dir1": {
|
||||
"a": "A",
|
||||
"b": "B"
|
||||
},
|
||||
"dir2": {
|
||||
"c": "C",
|
||||
"dir3": {
|
||||
"d": "D"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/dir1/a")),
|
||||
PathBuf::from(path!("/root/dir1/b")),
|
||||
PathBuf::from(path!("/root/dir2/c")),
|
||||
PathBuf::from(path!("/root/dir2/dir3/d")),
|
||||
]
|
||||
);
|
||||
|
||||
fs.create_symlink(path!("/root/dir2/link-to-dir3").as_ref(), "./dir3".into())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.canonicalize(path!("/root/dir2/link-to-dir3").as_ref())
|
||||
.await
|
||||
.unwrap(),
|
||||
PathBuf::from(path!("/root/dir2/dir3")),
|
||||
);
|
||||
assert_eq!(
|
||||
fs.canonicalize(path!("/root/dir2/link-to-dir3/d").as_ref())
|
||||
.await
|
||||
.unwrap(),
|
||||
PathBuf::from(path!("/root/dir2/dir3/d")),
|
||||
);
|
||||
assert_eq!(
|
||||
fs.load(path!("/root/dir2/link-to-dir3/d").as_ref())
|
||||
.await
|
||||
.unwrap(),
|
||||
"D",
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_copy_recursive_with_single_file(executor: BackgroundExecutor) {
|
||||
let fs = FakeFs::new(executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/outer"),
|
||||
json!({
|
||||
"a": "A",
|
||||
"b": "B",
|
||||
"inner": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/outer/a")),
|
||||
PathBuf::from(path!("/outer/b")),
|
||||
]
|
||||
);
|
||||
|
||||
let source = Path::new(path!("/outer/a"));
|
||||
let target = Path::new(path!("/outer/a copy"));
|
||||
copy_recursive(fs.as_ref(), source, target, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/outer/a")),
|
||||
PathBuf::from(path!("/outer/a copy")),
|
||||
PathBuf::from(path!("/outer/b")),
|
||||
]
|
||||
);
|
||||
|
||||
let source = Path::new(path!("/outer/a"));
|
||||
let target = Path::new(path!("/outer/inner/a copy"));
|
||||
copy_recursive(fs.as_ref(), source, target, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/outer/a")),
|
||||
PathBuf::from(path!("/outer/a copy")),
|
||||
PathBuf::from(path!("/outer/b")),
|
||||
PathBuf::from(path!("/outer/inner/a copy")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_copy_recursive_with_single_dir(executor: BackgroundExecutor) {
|
||||
let fs = FakeFs::new(executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/outer"),
|
||||
json!({
|
||||
"a": "A",
|
||||
"empty": {},
|
||||
"non-empty": {
|
||||
"b": "B",
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/outer/a")),
|
||||
PathBuf::from(path!("/outer/non-empty/b")),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
fs.directories(false),
|
||||
vec![
|
||||
PathBuf::from(path!("/")),
|
||||
PathBuf::from(path!("/outer")),
|
||||
PathBuf::from(path!("/outer/empty")),
|
||||
PathBuf::from(path!("/outer/non-empty")),
|
||||
]
|
||||
);
|
||||
|
||||
let source = Path::new(path!("/outer/empty"));
|
||||
let target = Path::new(path!("/outer/empty copy"));
|
||||
copy_recursive(fs.as_ref(), source, target, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/outer/a")),
|
||||
PathBuf::from(path!("/outer/non-empty/b")),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
fs.directories(false),
|
||||
vec![
|
||||
PathBuf::from(path!("/")),
|
||||
PathBuf::from(path!("/outer")),
|
||||
PathBuf::from(path!("/outer/empty")),
|
||||
PathBuf::from(path!("/outer/empty copy")),
|
||||
PathBuf::from(path!("/outer/non-empty")),
|
||||
]
|
||||
);
|
||||
|
||||
let source = Path::new(path!("/outer/non-empty"));
|
||||
let target = Path::new(path!("/outer/non-empty copy"));
|
||||
copy_recursive(fs.as_ref(), source, target, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/outer/a")),
|
||||
PathBuf::from(path!("/outer/non-empty/b")),
|
||||
PathBuf::from(path!("/outer/non-empty copy/b")),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
fs.directories(false),
|
||||
vec![
|
||||
PathBuf::from(path!("/")),
|
||||
PathBuf::from(path!("/outer")),
|
||||
PathBuf::from(path!("/outer/empty")),
|
||||
PathBuf::from(path!("/outer/empty copy")),
|
||||
PathBuf::from(path!("/outer/non-empty")),
|
||||
PathBuf::from(path!("/outer/non-empty copy")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_copy_recursive(executor: BackgroundExecutor) {
|
||||
let fs = FakeFs::new(executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/outer"),
|
||||
json!({
|
||||
"inner1": {
|
||||
"a": "A",
|
||||
"b": "B",
|
||||
"inner3": {
|
||||
"d": "D",
|
||||
},
|
||||
"inner4": {}
|
||||
},
|
||||
"inner2": {
|
||||
"c": "C",
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/outer/inner1/a")),
|
||||
PathBuf::from(path!("/outer/inner1/b")),
|
||||
PathBuf::from(path!("/outer/inner2/c")),
|
||||
PathBuf::from(path!("/outer/inner1/inner3/d")),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
fs.directories(false),
|
||||
vec![
|
||||
PathBuf::from(path!("/")),
|
||||
PathBuf::from(path!("/outer")),
|
||||
PathBuf::from(path!("/outer/inner1")),
|
||||
PathBuf::from(path!("/outer/inner2")),
|
||||
PathBuf::from(path!("/outer/inner1/inner3")),
|
||||
PathBuf::from(path!("/outer/inner1/inner4")),
|
||||
]
|
||||
);
|
||||
|
||||
let source = Path::new(path!("/outer"));
|
||||
let target = Path::new(path!("/outer/inner1/outer"));
|
||||
copy_recursive(fs.as_ref(), source, target, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/outer/inner1/a")),
|
||||
PathBuf::from(path!("/outer/inner1/b")),
|
||||
PathBuf::from(path!("/outer/inner2/c")),
|
||||
PathBuf::from(path!("/outer/inner1/inner3/d")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1/a")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1/b")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner2/c")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1/inner3/d")),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
fs.directories(false),
|
||||
vec![
|
||||
PathBuf::from(path!("/")),
|
||||
PathBuf::from(path!("/outer")),
|
||||
PathBuf::from(path!("/outer/inner1")),
|
||||
PathBuf::from(path!("/outer/inner2")),
|
||||
PathBuf::from(path!("/outer/inner1/inner3")),
|
||||
PathBuf::from(path!("/outer/inner1/inner4")),
|
||||
PathBuf::from(path!("/outer/inner1/outer")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner2")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1/inner3")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1/inner4")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_copy_recursive_with_overwriting(executor: BackgroundExecutor) {
|
||||
let fs = FakeFs::new(executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/outer"),
|
||||
json!({
|
||||
"inner1": {
|
||||
"a": "A",
|
||||
"b": "B",
|
||||
"outer": {
|
||||
"inner1": {
|
||||
"a": "B"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inner2": {
|
||||
"c": "C",
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/outer/inner1/a")),
|
||||
PathBuf::from(path!("/outer/inner1/b")),
|
||||
PathBuf::from(path!("/outer/inner2/c")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1/a")),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
fs.load(path!("/outer/inner1/outer/inner1/a").as_ref())
|
||||
.await
|
||||
.unwrap(),
|
||||
"B",
|
||||
);
|
||||
|
||||
let source = Path::new(path!("/outer"));
|
||||
let target = Path::new(path!("/outer/inner1/outer"));
|
||||
copy_recursive(
|
||||
fs.as_ref(),
|
||||
source,
|
||||
target,
|
||||
CopyOptions {
|
||||
overwrite: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/outer/inner1/a")),
|
||||
PathBuf::from(path!("/outer/inner1/b")),
|
||||
PathBuf::from(path!("/outer/inner2/c")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1/a")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1/b")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner2/c")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1/outer/inner1/a")),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
fs.load(path!("/outer/inner1/outer/inner1/a").as_ref())
|
||||
.await
|
||||
.unwrap(),
|
||||
"A"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_copy_recursive_with_ignoring(executor: BackgroundExecutor) {
|
||||
let fs = FakeFs::new(executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/outer"),
|
||||
json!({
|
||||
"inner1": {
|
||||
"a": "A",
|
||||
"b": "B",
|
||||
"outer": {
|
||||
"inner1": {
|
||||
"a": "B"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inner2": {
|
||||
"c": "C",
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/outer/inner1/a")),
|
||||
PathBuf::from(path!("/outer/inner1/b")),
|
||||
PathBuf::from(path!("/outer/inner2/c")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1/a")),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
fs.load(path!("/outer/inner1/outer/inner1/a").as_ref())
|
||||
.await
|
||||
.unwrap(),
|
||||
"B",
|
||||
);
|
||||
|
||||
let source = Path::new(path!("/outer"));
|
||||
let target = Path::new(path!("/outer/inner1/outer"));
|
||||
copy_recursive(
|
||||
fs.as_ref(),
|
||||
source,
|
||||
target,
|
||||
CopyOptions {
|
||||
ignore_if_exists: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/outer/inner1/a")),
|
||||
PathBuf::from(path!("/outer/inner1/b")),
|
||||
PathBuf::from(path!("/outer/inner2/c")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1/a")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1/b")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner2/c")),
|
||||
PathBuf::from(path!("/outer/inner1/outer/inner1/outer/inner1/a")),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
fs.load(path!("/outer/inner1/outer/inner1/a").as_ref())
|
||||
.await
|
||||
.unwrap(),
|
||||
"B"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_realfs_atomic_write(executor: BackgroundExecutor) {
|
||||
// With the file handle still open, the file should be replaced
|
||||
// https://github.com/zed-industries/zed/issues/30054
|
||||
let fs = RealFs::new(None, executor);
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_to_be_replaced = temp_dir.path().join("file.txt");
|
||||
let mut file = std::fs::File::create_new(&file_to_be_replaced).unwrap();
|
||||
file.write_all(b"Hello").unwrap();
|
||||
// drop(file); // We still hold the file handle here
|
||||
let content = std::fs::read_to_string(&file_to_be_replaced).unwrap();
|
||||
assert_eq!(content, "Hello");
|
||||
gpui::block_on(fs.atomic_write(file_to_be_replaced.clone(), "World".into())).unwrap();
|
||||
let content = std::fs::read_to_string(&file_to_be_replaced).unwrap();
|
||||
assert_eq!(content, "World");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_realfs_atomic_write_non_existing_file(executor: BackgroundExecutor) {
|
||||
let fs = RealFs::new(None, executor);
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_to_be_replaced = temp_dir.path().join("file.txt");
|
||||
gpui::block_on(fs.atomic_write(file_to_be_replaced.clone(), "Hello".into())).unwrap();
|
||||
let content = std::fs::read_to_string(&file_to_be_replaced).unwrap();
|
||||
assert_eq!(content, "Hello");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn test_realfs_canonicalize(executor: BackgroundExecutor) {
|
||||
use util::paths::SanitizedPath;
|
||||
|
||||
let fs = RealFs::new(None, executor);
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file = temp_dir.path().join("test (1).txt");
|
||||
let file = SanitizedPath::new(&file);
|
||||
std::fs::write(&file, "test").unwrap();
|
||||
|
||||
let canonicalized = fs.canonicalize(file.as_path()).await;
|
||||
assert!(canonicalized.is_ok());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_rename(executor: BackgroundExecutor) {
|
||||
let fs = FakeFs::new(executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"src": {
|
||||
"file_a.txt": "content a",
|
||||
"file_b.txt": "content b"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
fs.rename(
|
||||
Path::new(path!("/root/src/file_a.txt")),
|
||||
Path::new(path!("/root/src/new/renamed_a.txt")),
|
||||
RenameOptions {
|
||||
create_parents: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Assert that the `file_a.txt` file was being renamed and moved to a
|
||||
// different directory that did not exist before.
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/src/file_b.txt")),
|
||||
PathBuf::from(path!("/root/src/new/renamed_a.txt")),
|
||||
]
|
||||
);
|
||||
|
||||
let result = fs
|
||||
.rename(
|
||||
Path::new(path!("/root/src/file_b.txt")),
|
||||
Path::new(path!("/root/src/old/renamed_b.txt")),
|
||||
RenameOptions {
|
||||
create_parents: false,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// Assert that the `file_b.txt` file was not renamed nor moved, as
|
||||
// `create_parents` was set to `false`.
|
||||
// different directory that did not exist before.
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/src/file_b.txt")),
|
||||
PathBuf::from(path!("/root/src/new/renamed_a.txt")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
|
||||
async fn test_realfs_parallel_rename_without_overwrite_preserves_losing_source(
|
||||
executor: BackgroundExecutor,
|
||||
) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let root = temp_dir.path();
|
||||
let source_a = root.join("dir_a/shared.txt");
|
||||
let source_b = root.join("dir_b/shared.txt");
|
||||
let target = root.join("shared.txt");
|
||||
|
||||
std::fs::create_dir_all(source_a.parent().unwrap()).unwrap();
|
||||
std::fs::create_dir_all(source_b.parent().unwrap()).unwrap();
|
||||
std::fs::write(&source_a, "from a").unwrap();
|
||||
std::fs::write(&source_b, "from b").unwrap();
|
||||
|
||||
let fs = RealFs::new(None, executor);
|
||||
let (first_result, second_result) = futures::future::join(
|
||||
fs.rename(&source_a, &target, RenameOptions::default()),
|
||||
fs.rename(&source_b, &target, RenameOptions::default()),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_ne!(first_result.is_ok(), second_result.is_ok());
|
||||
assert!(target.exists());
|
||||
assert_eq!(source_a.exists() as u8 + source_b.exists() as u8, 1);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
|
||||
async fn test_realfs_rename_ignore_if_exists_leaves_source_and_target_unchanged(
|
||||
executor: BackgroundExecutor,
|
||||
) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let root = temp_dir.path();
|
||||
let source = root.join("source.txt");
|
||||
let target = root.join("target.txt");
|
||||
|
||||
std::fs::write(&source, "from source").unwrap();
|
||||
std::fs::write(&target, "from target").unwrap();
|
||||
|
||||
let fs = RealFs::new(None, executor);
|
||||
let result = fs
|
||||
.rename(
|
||||
&source,
|
||||
&target,
|
||||
RenameOptions {
|
||||
ignore_if_exists: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(std::fs::read_to_string(&source).unwrap(), "from source");
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap(), "from target");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg(unix)]
|
||||
async fn test_realfs_broken_symlink_metadata(executor: BackgroundExecutor) {
|
||||
let tempdir = TempDir::new().unwrap();
|
||||
let path = tempdir.path();
|
||||
let fs = RealFs::new(None, executor);
|
||||
let symlink_path = path.join("symlink");
|
||||
gpui::block_on(fs.create_symlink(&symlink_path, PathBuf::from("file_a.txt"))).unwrap();
|
||||
let metadata = fs
|
||||
.metadata(&symlink_path)
|
||||
.await
|
||||
.expect("metadata call succeeds")
|
||||
.expect("metadata returned");
|
||||
assert!(metadata.is_symlink);
|
||||
assert!(!metadata.is_dir);
|
||||
assert!(!metadata.is_fifo);
|
||||
assert!(!metadata.is_executable);
|
||||
// don't care about len or mtime on symlinks?
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg(unix)]
|
||||
async fn test_realfs_symlink_loop_metadata(executor: BackgroundExecutor) {
|
||||
let tempdir = TempDir::new().unwrap();
|
||||
let path = tempdir.path();
|
||||
let fs = RealFs::new(None, executor);
|
||||
let symlink_path = path.join("symlink");
|
||||
gpui::block_on(fs.create_symlink(&symlink_path, PathBuf::from("symlink"))).unwrap();
|
||||
let metadata = fs
|
||||
.metadata(&symlink_path)
|
||||
.await
|
||||
.expect("metadata call succeeds")
|
||||
.expect("metadata returned");
|
||||
assert!(metadata.is_symlink);
|
||||
assert!(!metadata.is_dir);
|
||||
assert!(!metadata.is_fifo);
|
||||
assert!(!metadata.is_executable);
|
||||
// don't care about len or mtime on symlinks?
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_fake_fs_trash(executor: BackgroundExecutor) {
|
||||
let fs = FakeFs::new(executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"src": {
|
||||
"file_c.txt": "File C",
|
||||
"file_d.txt": "File D"
|
||||
},
|
||||
"file_a.txt": "File A",
|
||||
"file_b.txt": "File B",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Trashing a file.
|
||||
let root_path = PathBuf::from(path!("/root"));
|
||||
let path = path!("/root/file_a.txt").as_ref();
|
||||
let trashed_entry = fs
|
||||
.trash(path, Default::default())
|
||||
.await
|
||||
.expect("should be able to trash {path:?}");
|
||||
|
||||
assert_eq!(trashed_entry.name, "file_a.txt");
|
||||
assert_eq!(trashed_entry.original_parent, root_path);
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/file_b.txt")),
|
||||
PathBuf::from(path!("/root/src/file_c.txt")),
|
||||
PathBuf::from(path!("/root/src/file_d.txt"))
|
||||
]
|
||||
);
|
||||
|
||||
let trash_entries = fs.trash_entries();
|
||||
assert_eq!(trash_entries.len(), 1);
|
||||
assert_eq!(trash_entries[0].name, "file_a.txt");
|
||||
assert_eq!(trash_entries[0].original_parent, root_path);
|
||||
|
||||
// Trashing a directory.
|
||||
let path = path!("/root/src").as_ref();
|
||||
let trashed_entry = fs
|
||||
.trash(
|
||||
path,
|
||||
RemoveOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("should be able to trash {path:?}");
|
||||
|
||||
assert_eq!(trashed_entry.name, "src");
|
||||
assert_eq!(trashed_entry.original_parent, root_path);
|
||||
assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_b.txt"))]);
|
||||
|
||||
let trash_entries = fs.trash_entries();
|
||||
assert_eq!(trash_entries.len(), 2);
|
||||
assert_eq!(trash_entries[1].name, "src");
|
||||
assert_eq!(trash_entries[1].original_parent, root_path);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_fake_fs_restore(executor: BackgroundExecutor) {
|
||||
let fs = FakeFs::new(executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"src": {
|
||||
"file_a.txt": "File A",
|
||||
"file_b.txt": "File B",
|
||||
},
|
||||
"file_c.txt": "File C",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Providing a non-existent `TrashedEntry` should result in an error.
|
||||
let id = OsString::from("/trash/file_c.txt");
|
||||
let name = OsString::from("file_c.txt");
|
||||
let original_parent = PathBuf::from(path!("/root"));
|
||||
let trashed_entry = TrashedEntry {
|
||||
id,
|
||||
name,
|
||||
original_parent,
|
||||
};
|
||||
let result = fs.restore(trashed_entry).await;
|
||||
assert!(matches!(result, Err(TrashRestoreError::NotFound { .. })));
|
||||
|
||||
// Attempt deleting a file, asserting that the filesystem no longer reports
|
||||
// it as part of its list of files, restore it and verify that the list of
|
||||
// files and trash has been updated accordingly.
|
||||
let path = path!("/root/src/file_a.txt").as_ref();
|
||||
let trashed_entry = fs.trash(path, Default::default()).await.unwrap();
|
||||
|
||||
assert_eq!(fs.trash_entries().len(), 1);
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/file_c.txt")),
|
||||
PathBuf::from(path!("/root/src/file_b.txt"))
|
||||
]
|
||||
);
|
||||
|
||||
fs.restore(trashed_entry).await.unwrap();
|
||||
|
||||
assert_eq!(fs.trash_entries().len(), 0);
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/file_c.txt")),
|
||||
PathBuf::from(path!("/root/src/file_a.txt")),
|
||||
PathBuf::from(path!("/root/src/file_b.txt"))
|
||||
]
|
||||
);
|
||||
|
||||
// Deleting and restoring a directory should also remove all of its files
|
||||
// but create a single trashed entry, which should be removed after
|
||||
// restoration.
|
||||
let options = RemoveOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
};
|
||||
let path = path!("/root/src/").as_ref();
|
||||
let trashed_entry = fs.trash(path, options).await.unwrap();
|
||||
|
||||
assert_eq!(fs.trash_entries().len(), 1);
|
||||
assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
|
||||
|
||||
fs.restore(trashed_entry).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/file_c.txt")),
|
||||
PathBuf::from(path!("/root/src/file_a.txt")),
|
||||
PathBuf::from(path!("/root/src/file_b.txt"))
|
||||
]
|
||||
);
|
||||
assert_eq!(fs.trash_entries().len(), 0);
|
||||
|
||||
// A collision error should be returned in case a file is being restored to
|
||||
// a path where a file already exists.
|
||||
let path = path!("/root/src/file_a.txt").as_ref();
|
||||
let trashed_entry = fs.trash(path, Default::default()).await.unwrap();
|
||||
|
||||
assert_eq!(fs.trash_entries().len(), 1);
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/file_c.txt")),
|
||||
PathBuf::from(path!("/root/src/file_b.txt"))
|
||||
]
|
||||
);
|
||||
|
||||
fs.write(path, "New File A".as_bytes()).await.unwrap();
|
||||
|
||||
assert_eq!(fs.trash_entries().len(), 1);
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![
|
||||
PathBuf::from(path!("/root/file_c.txt")),
|
||||
PathBuf::from(path!("/root/src/file_a.txt")),
|
||||
PathBuf::from(path!("/root/src/file_b.txt"))
|
||||
]
|
||||
);
|
||||
|
||||
let file_contents = fs.files_with_contents(path);
|
||||
assert!(fs.restore(trashed_entry).await.is_err());
|
||||
assert_eq!(
|
||||
file_contents,
|
||||
vec![(PathBuf::from(path), b"New File A".to_vec())]
|
||||
);
|
||||
|
||||
// A collision error should be returned in case a directory is being
|
||||
// restored to a path where a directory already exists.
|
||||
let options = RemoveOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
};
|
||||
let path = path!("/root/src/").as_ref();
|
||||
let trashed_entry = fs.trash(path, options).await.unwrap();
|
||||
|
||||
assert_eq!(fs.trash_entries().len(), 2);
|
||||
assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
|
||||
|
||||
fs.create_dir(path).await.unwrap();
|
||||
|
||||
assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
|
||||
assert_eq!(fs.trash_entries().len(), 2);
|
||||
|
||||
let result = fs.restore(trashed_entry).await;
|
||||
assert!(result.is_err());
|
||||
|
||||
assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
|
||||
assert_eq!(fs.trash_entries().len(), 2);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[ignore = "stress test; run explicitly when needed"]
|
||||
async fn test_realfs_watch_stress_reports_missed_paths(
|
||||
executor: BackgroundExecutor,
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
const FILE_COUNT: usize = 32000;
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let fs = RealFs::new(None, executor.clone());
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let root = temp_dir.path();
|
||||
|
||||
let mut file_paths = Vec::with_capacity(FILE_COUNT);
|
||||
let mut expected_paths = BTreeSet::new();
|
||||
|
||||
for index in 0..FILE_COUNT {
|
||||
let dir_path = root.join(format!("dir-{index:04}"));
|
||||
let file_path = dir_path.join("file.txt");
|
||||
fs.create_dir(&dir_path).await.expect("create watched dir");
|
||||
fs.write(&file_path, b"before")
|
||||
.await
|
||||
.expect("create initial file");
|
||||
expected_paths.insert(file_path.clone());
|
||||
file_paths.push(file_path);
|
||||
}
|
||||
|
||||
let (mut events, watcher) = fs.watch(root, Duration::from_millis(10)).await;
|
||||
let _watcher = watcher;
|
||||
|
||||
for file_path in &expected_paths {
|
||||
_watcher
|
||||
.add(file_path.parent().expect("file has parent"))
|
||||
.expect("add explicit directory watch");
|
||||
}
|
||||
|
||||
for (index, file_path) in file_paths.iter().enumerate() {
|
||||
let content = format!("after-{index}");
|
||||
fs.write(file_path, content.as_bytes())
|
||||
.await
|
||||
.expect("modify watched file");
|
||||
}
|
||||
|
||||
let mut changed_paths = BTreeSet::new();
|
||||
let mut rescan_count: u32 = 0;
|
||||
let timeout = executor.timer(Duration::from_secs(10)).fuse();
|
||||
|
||||
futures::pin_mut!(timeout);
|
||||
|
||||
let mut ticks = 0;
|
||||
while ticks < 1000 {
|
||||
if let Some(batch) = events.next().fuse().now_or_never().flatten() {
|
||||
for event in batch {
|
||||
if event.kind == Some(PathEventKind::Rescan) {
|
||||
rescan_count += 1;
|
||||
}
|
||||
if expected_paths.contains(&event.path) {
|
||||
changed_paths.insert(event.path);
|
||||
}
|
||||
}
|
||||
if changed_paths.len() == expected_paths.len() {
|
||||
break;
|
||||
}
|
||||
ticks = 0;
|
||||
} else {
|
||||
ticks += 1;
|
||||
executor.timer(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
let missed_paths: BTreeSet<_> = expected_paths.difference(&changed_paths).cloned().collect();
|
||||
|
||||
eprintln!(
|
||||
"realfs watch stress: expected={}, observed={}, missed={}, rescan={}",
|
||||
expected_paths.len(),
|
||||
changed_paths.len(),
|
||||
missed_paths.len(),
|
||||
rescan_count
|
||||
);
|
||||
|
||||
assert!(
|
||||
missed_paths.is_empty() || rescan_count > 0,
|
||||
"missed {} paths without rescan being reported",
|
||||
missed_paths.len()
|
||||
);
|
||||
}
|
||||
2
crates/fs/tests/integration/main.rs
Normal file
2
crates/fs/tests/integration/main.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod fake_git_repo;
|
||||
mod fs;
|
||||
Reference in New Issue
Block a user