Add test coverage for FS events happening inside unloaded dirs

Max Brunsfeld created

Change summary

crates/fs/src/fs.rs                  | 11 ++++++++
crates/project/src/worktree.rs       | 35 +++++++++++++----------------
crates/project/src/worktree_tests.rs | 17 ++++++++++++++
3 files changed, 43 insertions(+), 20 deletions(-)

Detailed changes

crates/fs/src/fs.rs 🔗

@@ -388,6 +388,7 @@ struct FakeFsState {
     event_txs: Vec<smol::channel::Sender<Vec<fsevent::Event>>>,
     events_paused: bool,
     buffered_events: Vec<fsevent::Event>,
+    metadata_call_count: usize,
     read_dir_call_count: usize,
 }
 
@@ -538,6 +539,7 @@ impl FakeFs {
                 buffered_events: Vec::new(),
                 events_paused: false,
                 read_dir_call_count: 0,
+                metadata_call_count: 0,
             }),
         })
     }
@@ -774,10 +776,16 @@ impl FakeFs {
         result
     }
 
+    /// How many `read_dir` calls have been issued.
     pub fn read_dir_call_count(&self) -> usize {
         self.state.lock().read_dir_call_count
     }
 
+    /// How many `metadata` calls have been issued.
+    pub fn metadata_call_count(&self) -> usize {
+        self.state.lock().metadata_call_count
+    }
+
     async fn simulate_random_delay(&self) {
         self.executor
             .upgrade()
@@ -1098,7 +1106,8 @@ impl Fs for FakeFs {
     async fn metadata(&self, path: &Path) -> Result<Option<Metadata>> {
         self.simulate_random_delay().await;
         let path = normalize_path(path);
-        let state = self.state.lock();
+        let mut state = self.state.lock();
+        state.metadata_call_count += 1;
         if let Some((mut entry, _)) = state.try_read_path(&path, false) {
             let is_symlink = entry.lock().is_symlink();
             if is_symlink {

crates/project/src/worktree.rs 🔗

@@ -3774,25 +3774,22 @@ impl BackgroundScanner {
 
                 // Scan any directories that were previously ignored and weren't
                 // previously scanned.
-                if was_ignored
-                    && !entry.is_ignored
-                    && !entry.is_external
-                    && entry.kind == EntryKind::UnloadedDir
-                {
-                    job.scan_queue
-                        .try_send(ScanJob {
-                            abs_path: abs_path.clone(),
-                            path: entry.path.clone(),
-                            ignore_stack: child_ignore_stack.clone(),
-                            scan_queue: job.scan_queue.clone(),
-                            ancestor_inodes: self
-                                .state
-                                .lock()
-                                .snapshot
-                                .ancestor_inodes_for_path(&entry.path),
-                            is_external: false,
-                        })
-                        .unwrap();
+                if was_ignored && !entry.is_ignored && entry.kind.is_unloaded() {
+                    let state = self.state.lock();
+                    if state.should_scan_directory(&entry) {
+                        job.scan_queue
+                            .try_send(ScanJob {
+                                abs_path: abs_path.clone(),
+                                path: entry.path.clone(),
+                                ignore_stack: child_ignore_stack.clone(),
+                                scan_queue: job.scan_queue.clone(),
+                                ancestor_inodes: state
+                                    .snapshot
+                                    .ancestor_inodes_for_path(&entry.path),
+                                is_external: false,
+                            })
+                            .unwrap();
+                    }
                 }
 
                 job.ignore_queue

crates/project/src/worktree_tests.rs 🔗

@@ -454,6 +454,10 @@ async fn test_open_gitignored_files(cx: &mut TestAppContext) {
                         "b1.js": "b1",
                         "b2.js": "b2",
                     },
+                    "c": {
+                        "c1.js": "c1",
+                        "c2.js": "c2",
+                    }
                 },
             },
             "two": {
@@ -521,6 +525,7 @@ async fn test_open_gitignored_files(cx: &mut TestAppContext) {
                 (Path::new("one/node_modules/b"), true),
                 (Path::new("one/node_modules/b/b1.js"), true),
                 (Path::new("one/node_modules/b/b2.js"), true),
+                (Path::new("one/node_modules/c"), true),
                 (Path::new("two"), false),
                 (Path::new("two/x.js"), false),
                 (Path::new("two/y.js"), false),
@@ -564,6 +569,7 @@ async fn test_open_gitignored_files(cx: &mut TestAppContext) {
                 (Path::new("one/node_modules/b"), true),
                 (Path::new("one/node_modules/b/b1.js"), true),
                 (Path::new("one/node_modules/b/b2.js"), true),
+                (Path::new("one/node_modules/c"), true),
                 (Path::new("two"), false),
                 (Path::new("two/x.js"), false),
                 (Path::new("two/y.js"), false),
@@ -578,6 +584,17 @@ async fn test_open_gitignored_files(cx: &mut TestAppContext) {
         // Only the newly-expanded directory is scanned.
         assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 1);
     });
+
+    // No work happens when files and directories change within an unloaded directory.
+    let prev_fs_call_count = fs.read_dir_call_count() + fs.metadata_call_count();
+    fs.create_dir("/root/one/node_modules/c/lib".as_ref())
+        .await
+        .unwrap();
+    cx.foreground().run_until_parked();
+    assert_eq!(
+        fs.read_dir_call_count() + fs.metadata_call_count() - prev_fs_call_count,
+        0
+    );
 }
 
 #[gpui::test]