@@ -428,7 +428,7 @@ impl Worktree {
let mut entry = Entry::new(
RelPath::empty().into(),
&metadata,
- &next_entry_id,
+ ProjectEntryId::new(&next_entry_id),
snapshot.root_char_bag,
None,
);
@@ -2736,13 +2736,30 @@ impl BackgroundScannerState {
}
}
- async fn insert_entry(
+ fn entry_id_for(
&mut self,
- mut entry: Entry,
- fs: &dyn Fs,
- watcher: &dyn Watcher,
- ) -> Entry {
- self.reuse_entry_id(&mut entry);
+ next_entry_id: &AtomicUsize,
+ path: &RelPath,
+ metadata: &fs::Metadata,
+ ) -> ProjectEntryId {
+ // If an entry with the same inode was removed from the worktree during this scan,
+ // then it *might* represent the same file or directory. But the OS might also have
+ // re-used the inode for a completely different file or directory.
+ //
+ // Conditionally reuse the old entry's id:
+ // * if the mtime is the same, the file was probably been renamed.
+ // * if the path is the same, the file may just have been updated
+ if let Some(removed_entry) = self.removed_entries.remove(&metadata.inode) {
+ if removed_entry.mtime == Some(metadata.mtime) || *removed_entry.path == *path {
+ return removed_entry.id;
+ }
+ } else if let Some(existing_entry) = self.snapshot.entry_for_path(path) {
+ return existing_entry.id;
+ }
+ ProjectEntryId::new(next_entry_id)
+ }
+
+ async fn insert_entry(&mut self, entry: Entry, fs: &dyn Fs, watcher: &dyn Watcher) -> Entry {
let entry = self.snapshot.insert_entry(entry, fs);
if entry.path.file_name() == Some(&DOT_GIT) {
self.insert_git_repository(entry.path.clone(), fs, watcher)
@@ -3389,13 +3406,13 @@ impl Entry {
fn new(
path: Arc<RelPath>,
metadata: &fs::Metadata,
- next_entry_id: &AtomicUsize,
+ id: ProjectEntryId,
root_char_bag: CharBag,
canonical_path: Option<Arc<Path>>,
) -> Self {
let char_bag = char_bag_for_path(root_char_bag, &path);
Self {
- id: ProjectEntryId::new(next_entry_id),
+ id,
kind: if metadata.is_dir {
EntryKind::PendingDir
} else {
@@ -3682,8 +3699,10 @@ impl BackgroundScanner {
.await;
if ignore_stack.is_abs_path_ignored(root_abs_path.as_path(), true) {
root_entry.is_ignored = true;
+ let mut root_entry = root_entry.clone();
+ state.reuse_entry_id(&mut root_entry);
state
- .insert_entry(root_entry.clone(), self.fs.as_ref(), self.watcher.as_ref())
+ .insert_entry(root_entry, self.fs.as_ref(), self.watcher.as_ref())
.await;
}
if root_entry.is_dir() {
@@ -4289,7 +4308,7 @@ impl BackgroundScanner {
let mut child_entry = Entry::new(
child_path.clone(),
&child_metadata,
- &next_entry_id,
+ ProjectEntryId::new(&next_entry_id),
root_char_bag,
None,
);
@@ -4476,10 +4495,11 @@ impl BackgroundScanner {
.ignore_stack_for_abs_path(&abs_path, metadata.is_dir, self.fs.as_ref())
.await;
let is_external = !canonical_path.starts_with(&root_canonical_path);
+ let entry_id = state.entry_id_for(self.next_entry_id.as_ref(), path, &metadata);
let mut fs_entry = Entry::new(
path.clone(),
&metadata,
- self.next_entry_id.as_ref(),
+ entry_id,
state.snapshot.root_char_bag,
if metadata.is_symlink {
Some(canonical_path.as_path().to_path_buf().into())
@@ -1533,6 +1533,175 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) {
});
}
+#[gpui::test]
+async fn test_create_file_in_expanded_gitignored_dir(cx: &mut TestAppContext) {
+ // Tests the behavior of our worktree refresh when a file in a gitignored directory
+ // is created.
+ init_test(cx);
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(
+ "/root",
+ json!({
+ ".gitignore": "ignored_dir\n",
+ "ignored_dir": {
+ "existing_file.txt": "existing content",
+ "another_file.txt": "another content",
+ },
+ }),
+ )
+ .await;
+
+ let tree = Worktree::local(
+ Path::new("/root"),
+ true,
+ fs.clone(),
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+
+ cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+ .await;
+
+ tree.read_with(cx, |tree, _| {
+ let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap();
+ assert!(ignored_dir.is_ignored);
+ assert_eq!(ignored_dir.kind, EntryKind::UnloadedDir);
+ });
+
+ tree.update(cx, |tree, cx| {
+ tree.load_file(rel_path("ignored_dir/existing_file.txt"), cx)
+ })
+ .await
+ .unwrap();
+
+ tree.read_with(cx, |tree, _| {
+ let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap();
+ assert!(ignored_dir.is_ignored);
+ assert_eq!(ignored_dir.kind, EntryKind::Dir);
+
+ assert!(
+ tree.entry_for_path(rel_path("ignored_dir/existing_file.txt"))
+ .is_some()
+ );
+ assert!(
+ tree.entry_for_path(rel_path("ignored_dir/another_file.txt"))
+ .is_some()
+ );
+ });
+
+ let entry = tree
+ .update(cx, |tree, cx| {
+ tree.create_entry(rel_path("ignored_dir/new_file.txt").into(), false, None, cx)
+ })
+ .await
+ .unwrap();
+ assert!(entry.into_included().is_some());
+
+ cx.executor().run_until_parked();
+
+ tree.read_with(cx, |tree, _| {
+ let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap();
+ assert!(ignored_dir.is_ignored);
+ assert_eq!(
+ ignored_dir.kind,
+ EntryKind::Dir,
+ "ignored_dir should still be loaded, not UnloadedDir"
+ );
+
+ assert!(
+ tree.entry_for_path(rel_path("ignored_dir/existing_file.txt"))
+ .is_some(),
+ "existing_file.txt should still be visible"
+ );
+ assert!(
+ tree.entry_for_path(rel_path("ignored_dir/another_file.txt"))
+ .is_some(),
+ "another_file.txt should still be visible"
+ );
+ assert!(
+ tree.entry_for_path(rel_path("ignored_dir/new_file.txt"))
+ .is_some(),
+ "new_file.txt should be visible"
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_fs_event_for_gitignored_dir_does_not_lose_contents(cx: &mut TestAppContext) {
+ // Tests the behavior of our worktree refresh when a directory modification for a gitignored directory
+ // is triggered.
+ init_test(cx);
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(
+ "/root",
+ json!({
+ ".gitignore": "ignored_dir\n",
+ "ignored_dir": {
+ "file1.txt": "content1",
+ "file2.txt": "content2",
+ },
+ }),
+ )
+ .await;
+
+ let tree = Worktree::local(
+ Path::new("/root"),
+ true,
+ fs.clone(),
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+
+ cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+ .await;
+
+ // Load a file to expand the ignored directory
+ tree.update(cx, |tree, cx| {
+ tree.load_file(rel_path("ignored_dir/file1.txt"), cx)
+ })
+ .await
+ .unwrap();
+
+ tree.read_with(cx, |tree, _| {
+ let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap();
+ assert_eq!(ignored_dir.kind, EntryKind::Dir);
+ assert!(
+ tree.entry_for_path(rel_path("ignored_dir/file1.txt"))
+ .is_some()
+ );
+ assert!(
+ tree.entry_for_path(rel_path("ignored_dir/file2.txt"))
+ .is_some()
+ );
+ });
+
+ fs.emit_fs_event("/root/ignored_dir", Some(fs::PathEventKind::Changed));
+ tree.flush_fs_events(cx).await;
+
+ tree.read_with(cx, |tree, _| {
+ let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap();
+ assert_eq!(
+ ignored_dir.kind,
+ EntryKind::Dir,
+ "ignored_dir should still be loaded (Dir), not UnloadedDir"
+ );
+ assert!(
+ tree.entry_for_path(rel_path("ignored_dir/file1.txt"))
+ .is_some(),
+ "file1.txt should still be visible after directory fs event"
+ );
+ assert!(
+ tree.entry_for_path(rel_path("ignored_dir/file2.txt"))
+ .is_some(),
+ "file2.txt should still be visible after directory fs event"
+ );
+ });
+}
+
#[gpui::test(iterations = 100)]
async fn test_random_worktree_operations_during_initial_scan(
cx: &mut TestAppContext,