On Linux and FreeBSD the native file watcher is non-recursive, so a watch on the .git directory itself does not report changes to files nested below it. Loose refs live in nested directories under refs, so external git commit, fetch, branch, and update-ref operations that don't also touch a direct child of .git (like the index) went entirely unnoticed. Watch every directory in the refs tree when a repository is inserted, and watch directories subsequently created under refs (new remotes, slash-named branches) as their creation events arrive. On platforms with recursive watchers these registrations dedupe against the existing recursive watch, making them free. --- Release Notes: - N/A or Added/Fixed/Improved ...
5776 lines
168 KiB
Rust
5776 lines
168 KiB
Rust
mod worktree_settings_tests;
|
|
|
|
use anyhow::Result;
|
|
use encoding_rs;
|
|
use fs::{FakeFs, Fs, PathEventKind, RealFs, RemoveOptions};
|
|
use git::{DOT_GIT, GITIGNORE, REPO_EXCLUDE};
|
|
use gpui::{AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Task, TestAppContext};
|
|
use parking_lot::Mutex;
|
|
use postage::stream::Stream;
|
|
use pretty_assertions::assert_eq;
|
|
use rand::prelude::*;
|
|
use rpc::{AnyProtoClient, NoopProtoClient, proto};
|
|
use worktree::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle};
|
|
|
|
use serde_json::json;
|
|
use settings::{SettingsStore, WorktreeId};
|
|
use std::{
|
|
cell::Cell,
|
|
env,
|
|
fmt::Write,
|
|
mem,
|
|
path::{Path, PathBuf},
|
|
rc::Rc,
|
|
sync::Arc,
|
|
};
|
|
use util::{
|
|
ResultExt, path,
|
|
paths::PathStyle,
|
|
rel_path::{RelPath, rel_path},
|
|
test::TempTree,
|
|
};
|
|
|
|
#[gpui::test]
|
|
async fn test_traversal(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
".gitignore": "a/b\n",
|
|
"a": {
|
|
"b": "",
|
|
"c": "",
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root"),
|
|
true,
|
|
fs,
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(false, 0)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
rel_path(""),
|
|
rel_path(".gitignore"),
|
|
rel_path("a"),
|
|
rel_path("a/c"),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
rel_path(""),
|
|
rel_path(".gitignore"),
|
|
rel_path("a"),
|
|
rel_path("a/b"),
|
|
rel_path("a/c"),
|
|
]
|
|
);
|
|
})
|
|
}
|
|
|
|
#[gpui::test(iterations = 10)]
|
|
async fn test_circular_symlinks(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"lib": {
|
|
"a": {
|
|
"a.txt": ""
|
|
},
|
|
"b": {
|
|
"b.txt": ""
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
fs.create_symlink("/root/lib/a/lib".as_ref(), "..".into())
|
|
.await
|
|
.unwrap();
|
|
fs.create_symlink("/root/lib/b/lib".as_ref(), "..".into())
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(false, 0)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
rel_path(""),
|
|
rel_path("lib"),
|
|
rel_path("lib/a"),
|
|
rel_path("lib/a/a.txt"),
|
|
rel_path("lib/a/lib"),
|
|
rel_path("lib/b"),
|
|
rel_path("lib/b/b.txt"),
|
|
rel_path("lib/b/lib"),
|
|
]
|
|
);
|
|
});
|
|
|
|
fs.rename(
|
|
Path::new("/root/lib/a/lib"),
|
|
Path::new("/root/lib/a/lib-2"),
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.executor().run_until_parked();
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(false, 0)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
rel_path(""),
|
|
rel_path("lib"),
|
|
rel_path("lib/a"),
|
|
rel_path("lib/a/a.txt"),
|
|
rel_path("lib/a/lib-2"),
|
|
rel_path("lib/b"),
|
|
rel_path("lib/b/b.txt"),
|
|
rel_path("lib/b/lib"),
|
|
]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"dir1": {
|
|
"deps": {
|
|
// symlinks here
|
|
},
|
|
"src": {
|
|
"a.rs": "",
|
|
"b.rs": "",
|
|
},
|
|
},
|
|
"dir2": {
|
|
"src": {
|
|
"c.rs": "",
|
|
"d.rs": "",
|
|
}
|
|
},
|
|
"dir3": {
|
|
"deps": {},
|
|
"src": {
|
|
"e.rs": "",
|
|
"f.rs": "",
|
|
"nested": {
|
|
"deep.rs": ""
|
|
}
|
|
},
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// These symlinks point to directories outside of the worktree's root, dir1.
|
|
fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into())
|
|
.await
|
|
.unwrap();
|
|
fs.create_symlink("/root/dir1/deps/dep-dir3".as_ref(), "../../dir3".into())
|
|
.await
|
|
.unwrap();
|
|
fs.create_symlink(
|
|
"/root/dir1/deps/dep-dir3-alias".as_ref(),
|
|
"../../dir3".into(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
fs.create_symlink(
|
|
"/root/dir1/deps/dep-dir3-nested".as_ref(),
|
|
"../../dir3/src/nested".into(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root/dir1"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
let tree_updates = Arc::new(Mutex::new(Vec::new()));
|
|
tree.update(cx, |_, cx| {
|
|
let tree_updates = tree_updates.clone();
|
|
cx.subscribe(&tree, move |_, _, event, _| {
|
|
if let Event::UpdatedEntries(update) = event {
|
|
tree_updates.lock().extend(
|
|
update
|
|
.iter()
|
|
.map(|(path, _, change)| (path.clone(), *change)),
|
|
);
|
|
}
|
|
})
|
|
.detach();
|
|
});
|
|
|
|
// The symlinked directories are not scanned by default.
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("deps"), false),
|
|
(rel_path("deps/dep-dir2"), true),
|
|
(rel_path("deps/dep-dir3"), true),
|
|
(rel_path("deps/dep-dir3-alias"), true),
|
|
(rel_path("deps/dep-dir3-nested"), true),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/a.rs"), false),
|
|
(rel_path("src/b.rs"), false),
|
|
]
|
|
);
|
|
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("deps/dep-dir2")).unwrap().kind,
|
|
EntryKind::UnloadedDir
|
|
);
|
|
});
|
|
|
|
// Expand one of the symlinked directories.
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("deps/dep-dir3").into()])
|
|
})
|
|
.recv()
|
|
.await;
|
|
|
|
// The expanded directory's contents are loaded. Subdirectories are
|
|
// not scanned yet.
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("deps"), false),
|
|
(rel_path("deps/dep-dir2"), true),
|
|
(rel_path("deps/dep-dir3"), true),
|
|
(rel_path("deps/dep-dir3/deps"), true),
|
|
(rel_path("deps/dep-dir3/src"), true),
|
|
(rel_path("deps/dep-dir3-alias"), true),
|
|
(rel_path("deps/dep-dir3-nested"), true),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/a.rs"), false),
|
|
(rel_path("src/b.rs"), false),
|
|
]
|
|
);
|
|
});
|
|
assert_eq!(
|
|
mem::take(&mut *tree_updates.lock()),
|
|
&[
|
|
(rel_path("deps/dep-dir3").into(), PathChange::Loaded),
|
|
(rel_path("deps/dep-dir3/deps").into(), PathChange::Loaded),
|
|
(rel_path("deps/dep-dir3/src").into(), PathChange::Loaded)
|
|
]
|
|
);
|
|
|
|
// Expand a subdirectory of one of the symlinked directories.
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("deps/dep-dir3/src").into()])
|
|
})
|
|
.recv()
|
|
.await;
|
|
|
|
// The expanded subdirectory's contents are loaded.
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("deps"), false),
|
|
(rel_path("deps/dep-dir2"), true),
|
|
(rel_path("deps/dep-dir3"), true),
|
|
(rel_path("deps/dep-dir3/deps"), true),
|
|
(rel_path("deps/dep-dir3/src"), true),
|
|
(rel_path("deps/dep-dir3/src/e.rs"), true),
|
|
(rel_path("deps/dep-dir3/src/f.rs"), true),
|
|
(rel_path("deps/dep-dir3/src/nested"), true),
|
|
(rel_path("deps/dep-dir3-alias"), true),
|
|
(rel_path("deps/dep-dir3-nested"), true),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/a.rs"), false),
|
|
(rel_path("src/b.rs"), false),
|
|
]
|
|
);
|
|
});
|
|
|
|
assert_eq!(
|
|
mem::take(&mut *tree_updates.lock()),
|
|
&[
|
|
(rel_path("deps/dep-dir3/src").into(), PathChange::Loaded),
|
|
(
|
|
rel_path("deps/dep-dir3/src/e.rs").into(),
|
|
PathChange::Loaded
|
|
),
|
|
(
|
|
rel_path("deps/dep-dir3/src/f.rs").into(),
|
|
PathChange::Loaded
|
|
),
|
|
(
|
|
rel_path("deps/dep-dir3/src/nested").into(),
|
|
PathChange::Loaded
|
|
)
|
|
]
|
|
);
|
|
|
|
// After an external symlink subtree is loaded, changes in the target should be reflected.
|
|
fs.insert_file(Path::new("/root/dir3/src/new.rs"), b"".to_vec())
|
|
.await;
|
|
|
|
wait_for_condition(cx, |cx| {
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.entry_for_path(rel_path("deps/dep-dir3/src/new.rs"))
|
|
.is_some()
|
|
})
|
|
})
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert!(
|
|
tree.entry_for_path(rel_path("deps/dep-dir3/src/new.rs"))
|
|
.is_some()
|
|
);
|
|
});
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("deps/dep-dir3-alias").into()])
|
|
})
|
|
.recv()
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("deps/dep-dir3-alias/src").into()])
|
|
})
|
|
.recv()
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("deps/dep-dir3-nested").into()])
|
|
})
|
|
.recv()
|
|
.await;
|
|
// Create a file in the shared target subtree. Because dep-dir3 and dep-dir3-alias both
|
|
// point to the same target, both logical paths should observe the new file.
|
|
fs.insert_file(Path::new("/root/dir3/src/shared-new.rs"), b"".to_vec())
|
|
.await;
|
|
|
|
wait_for_condition(cx, |cx| {
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.entry_for_path(rel_path("deps/dep-dir3/src/shared-new.rs"))
|
|
.is_some()
|
|
&& tree
|
|
.entry_for_path(rel_path("deps/dep-dir3-alias/src/shared-new.rs"))
|
|
.is_some()
|
|
})
|
|
})
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert!(
|
|
tree.entry_for_path(rel_path("deps/dep-dir3/src/shared-new.rs"))
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
tree.entry_for_path(rel_path("deps/dep-dir3-alias/src/shared-new.rs"))
|
|
.is_some()
|
|
);
|
|
});
|
|
|
|
// Create a file under the more specific nested target. Longest-prefix matching means this should appear under dep-dir3-nested
|
|
fs.insert_file(
|
|
Path::new("/root/dir3/src/nested/longest-prefix.rs"),
|
|
b"".to_vec(),
|
|
)
|
|
.await;
|
|
|
|
wait_for_condition(cx, |cx| {
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.entry_for_path(rel_path("deps/dep-dir3-nested/longest-prefix.rs"))
|
|
.is_some()
|
|
})
|
|
})
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert!(
|
|
tree.entry_for_path(rel_path("deps/dep-dir3-nested/longest-prefix.rs"))
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
tree.entry_for_path(rel_path("deps/dep-dir3/src/nested/longest-prefix.rs"))
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
tree.entry_for_path(rel_path("deps/dep-dir3-alias/src/nested/longest-prefix.rs"))
|
|
.is_none()
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_renaming_subdir_under_symlinked_root_keeps_children(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/target",
|
|
json!({
|
|
"file1.txt": "",
|
|
"file2.log": "",
|
|
"subdir-a": {
|
|
"config.ini": "",
|
|
},
|
|
"subdir-b": {
|
|
"nested": {
|
|
"note.md": "",
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
fs.create_symlink("/link".as_ref(), "/target".into())
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/link"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
fs.rename(
|
|
Path::new("/link/subdir-a"),
|
|
Path::new("/link/subdir-aa"),
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
wait_for_condition(cx, |cx| {
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.entry_for_path(rel_path("subdir-a")).is_none()
|
|
&& tree
|
|
.entry_for_path(rel_path("subdir-aa/config.ini"))
|
|
.is_some()
|
|
})
|
|
})
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
rel_path(""),
|
|
rel_path("file1.txt"),
|
|
rel_path("file2.log"),
|
|
rel_path("subdir-aa"),
|
|
rel_path("subdir-aa/config.ini"),
|
|
rel_path("subdir-b"),
|
|
rel_path("subdir-b/nested"),
|
|
rel_path("subdir-b/nested/note.md"),
|
|
]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_symlinked_dir_inside_project(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"project": {
|
|
"real-dir": {
|
|
"existing.rs": "",
|
|
"nested": {
|
|
"deep.rs": ""
|
|
}
|
|
},
|
|
"links": {}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.create_symlink(
|
|
"/root/project/links/internal".as_ref(),
|
|
"../real-dir".into(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root/project"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("links"), false),
|
|
(rel_path("links/internal"), false),
|
|
(rel_path("links/internal/existing.rs"), false),
|
|
(rel_path("links/internal/nested"), false),
|
|
(rel_path("links/internal/nested/deep.rs"), false),
|
|
(rel_path("real-dir"), false),
|
|
(rel_path("real-dir/existing.rs"), false),
|
|
(rel_path("real-dir/nested"), false),
|
|
(rel_path("real-dir/nested/deep.rs"), false),
|
|
]
|
|
);
|
|
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("links/internal"))
|
|
.unwrap()
|
|
.kind,
|
|
EntryKind::Dir
|
|
);
|
|
});
|
|
|
|
fs.insert_file(Path::new("/root/project/real-dir/new.txt"), b"".to_vec())
|
|
.await;
|
|
wait_for_condition(cx, |cx| {
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.entry_for_path(rel_path("links/internal/new.txt"))
|
|
.is_some()
|
|
})
|
|
})
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert!(
|
|
tree.entry_for_path(rel_path("links/internal/new.txt"))
|
|
.is_some()
|
|
);
|
|
});
|
|
|
|
fs.insert_file(
|
|
Path::new("/root/project/real-dir/nested/inner.txt"),
|
|
b"".to_vec(),
|
|
)
|
|
.await;
|
|
wait_for_condition(cx, |cx| {
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.entry_for_path(rel_path("links/internal/nested/inner.txt"))
|
|
.is_some()
|
|
})
|
|
})
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert!(
|
|
tree.entry_for_path(rel_path("links/internal/nested/inner.txt"))
|
|
.is_some()
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_scan_symlinks_always(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.scan_symlinks =
|
|
Some(settings::ScanSymlinksSetting::Always);
|
|
});
|
|
});
|
|
});
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"dir1": {
|
|
"deps": {
|
|
// symlink target placed here by create_symlink below
|
|
},
|
|
"src": {
|
|
"a.rs": "",
|
|
},
|
|
},
|
|
"dir2": {
|
|
"src": {
|
|
"b.rs": "",
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into())
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root/dir1"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
// With scan_symlinks = Always, the symlinked directory's contents should be
|
|
// fully visible on the first scan without any manual expansion.
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("deps"), false),
|
|
(rel_path("deps/dep-dir2"), true),
|
|
(rel_path("deps/dep-dir2/src"), true),
|
|
(rel_path("deps/dep-dir2/src/b.rs"), true),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/a.rs"), false),
|
|
]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_scan_symlinks_expanded(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
// scan_symlinks defaults to Expanded — no settings change needed.
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"dir1": {
|
|
"deps": {
|
|
// symlink target placed here by create_symlink below
|
|
},
|
|
"src": {
|
|
"a.rs": "",
|
|
},
|
|
},
|
|
"dir2": {
|
|
"src": {
|
|
"b.rs": "",
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into())
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root/dir1"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
// With the default scan_symlinks = Expanded, the symlinked directory
|
|
// should appear as an UnloadedDir entry with no children visible.
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("deps"), false),
|
|
(rel_path("deps/dep-dir2"), true),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/a.rs"), false),
|
|
]
|
|
);
|
|
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("deps/dep-dir2")).unwrap().kind,
|
|
EntryKind::UnloadedDir
|
|
);
|
|
});
|
|
|
|
// Manually expand the symlinked directory.
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("deps/dep-dir2").into()])
|
|
})
|
|
.recv()
|
|
.await;
|
|
|
|
// After expansion, dep-dir2's immediate children are visible. Subdirectories
|
|
// within it are present but not yet scanned.
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("deps"), false),
|
|
(rel_path("deps/dep-dir2"), true),
|
|
(rel_path("deps/dep-dir2/src"), true),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/a.rs"), false),
|
|
]
|
|
);
|
|
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("deps/dep-dir2/src"))
|
|
.unwrap()
|
|
.kind,
|
|
EntryKind::UnloadedDir
|
|
);
|
|
});
|
|
|
|
// Expand the subdirectory inside the symlinked directory.
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("deps/dep-dir2/src").into()])
|
|
})
|
|
.recv()
|
|
.await;
|
|
|
|
// After expanding the subdirectory, its files are visible.
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("deps"), false),
|
|
(rel_path("deps/dep-dir2"), true),
|
|
(rel_path("deps/dep-dir2/src"), true),
|
|
(rel_path("deps/dep-dir2/src/b.rs"), true),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/a.rs"), false),
|
|
]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test(iterations = 10)]
|
|
async fn test_circular_symlinks_always(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.scan_symlinks =
|
|
Some(settings::ScanSymlinksSetting::Always);
|
|
});
|
|
});
|
|
});
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"project": {
|
|
"lib": {
|
|
"a": {
|
|
"a.txt": ""
|
|
}
|
|
},
|
|
"deps": {}
|
|
},
|
|
"outside": {
|
|
"data.txt": ""
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.create_symlink("/root/project/deps/ext".as_ref(), "../../outside".into())
|
|
.await
|
|
.unwrap();
|
|
fs.create_symlink("/root/outside/back".as_ref(), "../../project".into())
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root/project"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
let entries: Vec<_> = tree
|
|
.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
entries,
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("deps"), false),
|
|
(rel_path("deps/ext"), true),
|
|
(rel_path("deps/ext/data.txt"), true),
|
|
(rel_path("lib"), false),
|
|
(rel_path("lib/a"), false),
|
|
(rel_path("lib/a/a.txt"), false),
|
|
]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_scan_symlinks_always_respects_gitignore(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.scan_symlinks =
|
|
Some(settings::ScanSymlinksSetting::Always);
|
|
});
|
|
});
|
|
});
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"project": {
|
|
".gitignore": "ignored-dep\n",
|
|
"deps": {}
|
|
},
|
|
"external-included": {
|
|
"src": {
|
|
"included.rs": ""
|
|
}
|
|
},
|
|
"external-ignored": {
|
|
"src": {
|
|
"ignored.rs": ""
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.create_symlink(
|
|
"/root/project/deps/included-dep".as_ref(),
|
|
"../../external-included".into(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
fs.create_symlink(
|
|
"/root/project/deps/ignored-dep".as_ref(),
|
|
"../../external-ignored".into(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root/project"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external, entry.is_ignored))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false, false),
|
|
(rel_path(".gitignore"), false, false),
|
|
(rel_path("deps"), false, false),
|
|
(rel_path("deps/ignored-dep"), true, true),
|
|
(rel_path("deps/included-dep"), true, false),
|
|
(rel_path("deps/included-dep/src"), true, false),
|
|
(rel_path("deps/included-dep/src/included.rs"), true, false),
|
|
]
|
|
);
|
|
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("deps/ignored-dep"))
|
|
.unwrap()
|
|
.kind,
|
|
EntryKind::UnloadedDir
|
|
);
|
|
});
|
|
}
|
|
|
|
// Real-fs counterparts to the FakeFs scan_symlinks tests above. FakeFs does not
|
|
// model `fs::canonicalize` against a real filesystem, so platform-specific
|
|
// canonicalization or readdir behavior is not covered by the FakeFs tests.
|
|
// These tests use a real temp directory and a real symlink to exercise the
|
|
// production path on the host platform.
|
|
#[cfg(unix)]
|
|
#[gpui::test]
|
|
async fn test_real_fs_scan_symlinks_always(cx: &mut TestAppContext) {
|
|
cx.executor().allow_parking();
|
|
init_test(cx);
|
|
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.scan_symlinks =
|
|
Some(settings::ScanSymlinksSetting::Always);
|
|
});
|
|
});
|
|
});
|
|
|
|
let temp_root = TempTree::new(json!({
|
|
"project": {
|
|
"deps": {},
|
|
"src": {
|
|
"a.rs": "",
|
|
},
|
|
},
|
|
"external": {
|
|
"src": {
|
|
"b.rs": "",
|
|
},
|
|
},
|
|
}));
|
|
|
|
// Relative symlink: from temp_root/project/deps/, `../../external` resolves
|
|
// to temp_root/external — outside the worktree root at temp_root/project.
|
|
std::os::unix::fs::symlink(
|
|
"../../external",
|
|
temp_root.path().join("project/deps/dep-external"),
|
|
)
|
|
.unwrap();
|
|
|
|
let project_root = temp_root.path().join("project");
|
|
let tree = Worktree::local(
|
|
project_root.as_path(),
|
|
true,
|
|
Arc::new(RealFs::new(None, cx.executor())),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("deps"), false),
|
|
(rel_path("deps/dep-external"), true),
|
|
(rel_path("deps/dep-external/src"), true),
|
|
(rel_path("deps/dep-external/src/b.rs"), true),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/a.rs"), false),
|
|
]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[gpui::test]
|
|
async fn test_real_fs_scan_symlinks_expanded(cx: &mut TestAppContext) {
|
|
cx.executor().allow_parking();
|
|
init_test(cx);
|
|
|
|
// scan_symlinks defaults to Expanded — no settings change needed.
|
|
|
|
let temp_root = TempTree::new(json!({
|
|
"project": {
|
|
"deps": {},
|
|
"src": {
|
|
"a.rs": "",
|
|
},
|
|
},
|
|
"external": {
|
|
"src": {
|
|
"b.rs": "",
|
|
},
|
|
},
|
|
}));
|
|
|
|
std::os::unix::fs::symlink(
|
|
"../../external",
|
|
temp_root.path().join("project/deps/dep-external"),
|
|
)
|
|
.unwrap();
|
|
|
|
let project_root = temp_root.path().join("project");
|
|
let tree = Worktree::local(
|
|
project_root.as_path(),
|
|
true,
|
|
Arc::new(RealFs::new(None, cx.executor())),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
// Before expansion, the symlinked directory should appear as an UnloadedDir
|
|
// with no children visible.
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("deps"), false),
|
|
(rel_path("deps/dep-external"), true),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/a.rs"), false),
|
|
]
|
|
);
|
|
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("deps/dep-external"))
|
|
.unwrap()
|
|
.kind,
|
|
EntryKind::UnloadedDir
|
|
);
|
|
});
|
|
|
|
// Manually expand the symlinked directory. This is the case #51382 was
|
|
// added to fix; if this assertion fails it's a regression of that fix on
|
|
// real filesystems.
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("deps/dep-external").into()])
|
|
})
|
|
.recv()
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("deps"), false),
|
|
(rel_path("deps/dep-external"), true),
|
|
(rel_path("deps/dep-external/src"), true),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/a.rs"), false),
|
|
]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_internal_symlink_updates_preserve_entry_ids(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"project": {
|
|
"real-dir": {
|
|
"existing.rs": "old",
|
|
},
|
|
"links": {}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.create_symlink(
|
|
"/root/project/links/internal".as_ref(),
|
|
"../real-dir".into(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root/project"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
let (real_entry_id, symlink_entry_id, old_mtime) = tree.read_with(cx, |tree, _| {
|
|
let real_entry = tree
|
|
.entry_for_path(rel_path("real-dir/existing.rs"))
|
|
.unwrap();
|
|
let symlink_entry = tree
|
|
.entry_for_path(rel_path("links/internal/existing.rs"))
|
|
.unwrap();
|
|
assert_eq!(real_entry.inode, symlink_entry.inode);
|
|
assert_eq!(real_entry.mtime, symlink_entry.mtime);
|
|
assert_ne!(real_entry.id, symlink_entry.id);
|
|
(real_entry.id, symlink_entry.id, real_entry.mtime)
|
|
});
|
|
|
|
fs.write(Path::new("/root/project/real-dir/existing.rs"), b"new")
|
|
.await
|
|
.unwrap();
|
|
|
|
wait_for_condition(cx, |cx| {
|
|
tree.read_with(cx, |tree, _| {
|
|
let real_entry = tree
|
|
.entry_for_path(rel_path("real-dir/existing.rs"))
|
|
.unwrap();
|
|
let symlink_entry = tree
|
|
.entry_for_path(rel_path("links/internal/existing.rs"))
|
|
.unwrap();
|
|
real_entry.mtime != old_mtime && symlink_entry.mtime != old_mtime
|
|
})
|
|
})
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
let real_entry = tree
|
|
.entry_for_path(rel_path("real-dir/existing.rs"))
|
|
.unwrap();
|
|
let symlink_entry = tree
|
|
.entry_for_path(rel_path("links/internal/existing.rs"))
|
|
.unwrap();
|
|
|
|
assert_eq!(real_entry.inode, symlink_entry.inode);
|
|
assert_eq!(real_entry.id, real_entry_id);
|
|
assert_eq!(
|
|
tree.entry_for_id(real_entry_id).unwrap().path.as_ref(),
|
|
rel_path("real-dir/existing.rs")
|
|
);
|
|
assert_eq!(symlink_entry.id, symlink_entry_id);
|
|
assert_eq!(
|
|
tree.entry_for_id(symlink_entry_id).unwrap().path.as_ref(),
|
|
rel_path("links/internal/existing.rs")
|
|
);
|
|
});
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[gpui::test]
|
|
async fn test_renaming_case_only(cx: &mut TestAppContext) {
|
|
cx.executor().allow_parking();
|
|
init_test(cx);
|
|
|
|
const OLD_NAME: &str = "aaa.rs";
|
|
const NEW_NAME: &str = "AAA.rs";
|
|
|
|
let fs = Arc::new(RealFs::new(None, cx.executor()));
|
|
let temp_root = TempTree::new(json!({
|
|
OLD_NAME: "",
|
|
}));
|
|
|
|
let tree = Worktree::local(
|
|
temp_root.path(),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
vec![rel_path(""), rel_path(OLD_NAME)]
|
|
);
|
|
});
|
|
|
|
fs.rename(
|
|
&temp_root.path().join(OLD_NAME),
|
|
&temp_root.path().join(NEW_NAME),
|
|
fs::RenameOptions {
|
|
overwrite: true,
|
|
ignore_if_exists: true,
|
|
create_parents: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
tree.flush_fs_events(cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
vec![rel_path(""), rel_path(NEW_NAME)]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_root_rescan_reconciles_stale_state(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"old.txt": "",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
vec![rel_path(""), rel_path("old.txt")]
|
|
);
|
|
});
|
|
|
|
fs.pause_events();
|
|
fs.remove_file(Path::new("/root/old.txt"), RemoveOptions::default())
|
|
.await
|
|
.unwrap();
|
|
fs.insert_file(Path::new("/root/new.txt"), Vec::new()).await;
|
|
assert_eq!(fs.buffered_event_count(), 2);
|
|
fs.clear_buffered_events();
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert!(tree.entry_for_path(rel_path("old.txt")).is_some());
|
|
assert!(tree.entry_for_path(rel_path("new.txt")).is_none());
|
|
});
|
|
|
|
fs.emit_fs_event("/root", Some(fs::PathEventKind::Rescan));
|
|
fs.unpause_events_and_flush();
|
|
tree.flush_fs_events(cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert!(tree.entry_for_path(rel_path("old.txt")).is_none());
|
|
assert!(tree.entry_for_path(rel_path("new.txt")).is_some());
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
vec![rel_path(""), rel_path("new.txt")]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_root_rescan_does_not_miss_event_before_readding_root_watcher(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree("/root", json!({})).await;
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
fs.create_file_before_next_watch_add("/root", "/root/created-before-root-readd.txt");
|
|
fs.emit_fs_event("/root", Some(PathEventKind::Rescan));
|
|
|
|
wait_for_condition(cx, |cx| {
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.entry_for_path(rel_path("created-before-root-readd.txt"))
|
|
.is_some()
|
|
})
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_subtree_rescan_reports_unchanged_descendants_as_updated(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"dir": {
|
|
"child.txt": "",
|
|
"nested": {
|
|
"grandchild.txt": "",
|
|
},
|
|
"remove": {
|
|
"removed.txt": "",
|
|
}
|
|
},
|
|
"other.txt": "",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
let tree_updates = Arc::new(Mutex::new(Vec::new()));
|
|
tree.update(cx, |_, cx| {
|
|
let tree_updates = tree_updates.clone();
|
|
cx.subscribe(&tree, move |_, _, event, _| {
|
|
if let Event::UpdatedEntries(update) = event {
|
|
tree_updates.lock().extend(
|
|
update
|
|
.iter()
|
|
.filter(|(path, _, _)| path.as_ref() != rel_path("fs-event-sentinel"))
|
|
.map(|(path, _, change)| (path.clone(), *change)),
|
|
);
|
|
}
|
|
})
|
|
.detach();
|
|
});
|
|
fs.pause_events();
|
|
fs.insert_file("/root/dir/new.txt", b"new content".to_vec())
|
|
.await;
|
|
fs.remove_dir(
|
|
"/root/dir/remove".as_ref(),
|
|
RemoveOptions {
|
|
recursive: true,
|
|
ignore_if_not_exists: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
fs.clear_buffered_events();
|
|
fs.unpause_events_and_flush();
|
|
|
|
fs.emit_fs_event("/root/dir", Some(fs::PathEventKind::Rescan));
|
|
tree.flush_fs_events(cx).await;
|
|
|
|
assert_eq!(
|
|
mem::take(&mut *tree_updates.lock()),
|
|
&[
|
|
(rel_path("dir").into(), PathChange::Updated),
|
|
(rel_path("dir/child.txt").into(), PathChange::Updated),
|
|
(rel_path("dir/nested").into(), PathChange::Updated),
|
|
(
|
|
rel_path("dir/nested/grandchild.txt").into(),
|
|
PathChange::Updated
|
|
),
|
|
(rel_path("dir/new.txt").into(), PathChange::Added),
|
|
(rel_path("dir/remove").into(), PathChange::Removed),
|
|
(
|
|
rel_path("dir/remove/removed.txt").into(),
|
|
PathChange::Removed
|
|
),
|
|
]
|
|
);
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert!(tree.entry_for_path(rel_path("other.txt")).is_some());
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_open_gitignored_files(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
".gitignore": "node_modules\n",
|
|
"one": {
|
|
"node_modules": {
|
|
"a": {
|
|
"a1.js": "a1",
|
|
"a2.js": "a2",
|
|
},
|
|
"b": {
|
|
"b1.js": "b1",
|
|
"b2.js": "b2",
|
|
},
|
|
"c": {
|
|
"c1.js": "c1",
|
|
"c2.js": "c2",
|
|
}
|
|
},
|
|
},
|
|
"two": {
|
|
"x.js": "",
|
|
"y.js": "",
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_ignored))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path(".gitignore"), false),
|
|
(rel_path("one"), false),
|
|
(rel_path("one/node_modules"), true),
|
|
(rel_path("two"), false),
|
|
(rel_path("two/x.js"), false),
|
|
(rel_path("two/y.js"), false),
|
|
]
|
|
);
|
|
});
|
|
|
|
// Open a file that is nested inside of a gitignored directory that
|
|
// has not yet been expanded.
|
|
let prev_read_dir_count = fs.read_dir_call_count();
|
|
let loaded = tree
|
|
.update(cx, |tree, cx| {
|
|
tree.load_file(rel_path("one/node_modules/b/b1.js"), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_ignored))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path(".gitignore"), false),
|
|
(rel_path("one"), false),
|
|
(rel_path("one/node_modules"), true),
|
|
(rel_path("one/node_modules/a"), true),
|
|
(rel_path("one/node_modules/b"), true),
|
|
(rel_path("one/node_modules/b/b1.js"), true),
|
|
(rel_path("one/node_modules/b/b2.js"), true),
|
|
(rel_path("one/node_modules/c"), true),
|
|
(rel_path("two"), false),
|
|
(rel_path("two/x.js"), false),
|
|
(rel_path("two/y.js"), false),
|
|
]
|
|
);
|
|
|
|
assert_eq!(
|
|
loaded.file.path.as_ref(),
|
|
rel_path("one/node_modules/b/b1.js")
|
|
);
|
|
|
|
// Only the newly-expanded directories are scanned.
|
|
assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 2);
|
|
});
|
|
|
|
// Open another file in a different subdirectory of the same
|
|
// gitignored directory.
|
|
let prev_read_dir_count = fs.read_dir_call_count();
|
|
let loaded = tree
|
|
.update(cx, |tree, cx| {
|
|
tree.load_file(rel_path("one/node_modules/a/a2.js"), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_ignored))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path(".gitignore"), false),
|
|
(rel_path("one"), false),
|
|
(rel_path("one/node_modules"), true),
|
|
(rel_path("one/node_modules/a"), true),
|
|
(rel_path("one/node_modules/a/a1.js"), true),
|
|
(rel_path("one/node_modules/a/a2.js"), true),
|
|
(rel_path("one/node_modules/b"), true),
|
|
(rel_path("one/node_modules/b/b1.js"), true),
|
|
(rel_path("one/node_modules/b/b2.js"), true),
|
|
(rel_path("one/node_modules/c"), true),
|
|
(rel_path("two"), false),
|
|
(rel_path("two/x.js"), false),
|
|
(rel_path("two/y.js"), false),
|
|
]
|
|
);
|
|
|
|
assert_eq!(
|
|
loaded.file.path.as_ref(),
|
|
rel_path("one/node_modules/a/a2.js")
|
|
);
|
|
|
|
// Only the newly-expanded directory is scanned.
|
|
assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 1);
|
|
});
|
|
|
|
let path = PathBuf::from("/root/one/node_modules/c/lib");
|
|
|
|
// 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();
|
|
// When we open a directory, we check each ancestor whether it's a git
|
|
// repository. That means we have an fs.metadata call per ancestor that we
|
|
// need to subtract here.
|
|
let ancestors = path.ancestors().count();
|
|
|
|
fs.create_dir(path.as_ref()).await.unwrap();
|
|
cx.executor().run_until_parked();
|
|
|
|
assert_eq!(
|
|
fs.read_dir_call_count() + fs.metadata_call_count() - prev_fs_call_count - ancestors,
|
|
0
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_dirs_no_longer_ignored(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
".gitignore": "node_modules\n",
|
|
"a": {
|
|
"a.js": "",
|
|
},
|
|
"b": {
|
|
"b.js": "",
|
|
},
|
|
"node_modules": {
|
|
"c": {
|
|
"c.js": "",
|
|
},
|
|
"d": {
|
|
"d.js": "",
|
|
"e": {
|
|
"e1.js": "",
|
|
"e2.js": "",
|
|
},
|
|
"f": {
|
|
"f1.js": "",
|
|
"f2.js": "",
|
|
}
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
// Open a file within the gitignored directory, forcing some of its
|
|
// subdirectories to be read, but not all.
|
|
let read_dir_count_1 = fs.read_dir_call_count();
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("node_modules/d/d.js").into()])
|
|
})
|
|
.recv()
|
|
.await;
|
|
|
|
// Those subdirectories are now loaded.
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|e| (e.path.as_ref(), e.is_ignored))
|
|
.collect::<Vec<_>>(),
|
|
&[
|
|
(rel_path(""), false),
|
|
(rel_path(".gitignore"), false),
|
|
(rel_path("a"), false),
|
|
(rel_path("a/a.js"), false),
|
|
(rel_path("b"), false),
|
|
(rel_path("b/b.js"), false),
|
|
(rel_path("node_modules"), true),
|
|
(rel_path("node_modules/c"), true),
|
|
(rel_path("node_modules/d"), true),
|
|
(rel_path("node_modules/d/d.js"), true),
|
|
(rel_path("node_modules/d/e"), true),
|
|
(rel_path("node_modules/d/f"), true),
|
|
]
|
|
);
|
|
});
|
|
let read_dir_count_2 = fs.read_dir_call_count();
|
|
assert_eq!(read_dir_count_2 - read_dir_count_1, 2);
|
|
|
|
// Update the gitignore so that node_modules is no longer ignored,
|
|
// but a subdirectory is ignored
|
|
fs.save("/root/.gitignore".as_ref(), &"e".into(), Default::default())
|
|
.await
|
|
.unwrap();
|
|
cx.executor().run_until_parked();
|
|
|
|
// All of the directories that are no longer ignored are now loaded.
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|e| (e.path.as_ref(), e.is_ignored))
|
|
.collect::<Vec<_>>(),
|
|
&[
|
|
(rel_path(""), false),
|
|
(rel_path(".gitignore"), false),
|
|
(rel_path("a"), false),
|
|
(rel_path("a/a.js"), false),
|
|
(rel_path("b"), false),
|
|
(rel_path("b/b.js"), false),
|
|
// This directory is no longer ignored
|
|
(rel_path("node_modules"), false),
|
|
(rel_path("node_modules/c"), false),
|
|
(rel_path("node_modules/c/c.js"), false),
|
|
(rel_path("node_modules/d"), false),
|
|
(rel_path("node_modules/d/d.js"), false),
|
|
// This subdirectory is now ignored
|
|
(rel_path("node_modules/d/e"), true),
|
|
(rel_path("node_modules/d/f"), false),
|
|
(rel_path("node_modules/d/f/f1.js"), false),
|
|
(rel_path("node_modules/d/f/f2.js"), false),
|
|
]
|
|
);
|
|
});
|
|
|
|
// Each of the newly-loaded directories is scanned only once.
|
|
let read_dir_count_3 = fs.read_dir_call_count();
|
|
assert_eq!(read_dir_count_3 - read_dir_count_2, 2);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_write_file(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
cx.executor().allow_parking();
|
|
let dir = TempTree::new(json!({
|
|
".git": {},
|
|
".gitignore": "ignored-dir\n",
|
|
"tracked-dir": {},
|
|
"ignored-dir": {}
|
|
}));
|
|
|
|
let worktree = Worktree::local(
|
|
dir.path(),
|
|
true,
|
|
Arc::new(RealFs::new(None, cx.executor())),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
fs::fs_watcher::global(|_| {}).unwrap();
|
|
|
|
cx.read(|cx| worktree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
worktree.flush_fs_events(cx).await;
|
|
|
|
worktree
|
|
.update(cx, |tree, cx| {
|
|
tree.write_file(
|
|
rel_path("tracked-dir/file.txt").into(),
|
|
"hello".into(),
|
|
Default::default(),
|
|
encoding_rs::UTF_8,
|
|
false,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
worktree
|
|
.update(cx, |tree, cx| {
|
|
tree.write_file(
|
|
rel_path("ignored-dir/file.txt").into(),
|
|
"world".into(),
|
|
Default::default(),
|
|
encoding_rs::UTF_8,
|
|
false,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
worktree.read_with(cx, |tree, _| {
|
|
let tracked = tree
|
|
.entry_for_path(rel_path("tracked-dir/file.txt"))
|
|
.unwrap();
|
|
let ignored = tree
|
|
.entry_for_path(rel_path("ignored-dir/file.txt"))
|
|
.unwrap();
|
|
assert!(!tracked.is_ignored);
|
|
assert!(ignored.is_ignored);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_inclusions(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
cx.executor().allow_parking();
|
|
let dir = TempTree::new(json!({
|
|
".gitignore": "**/target\n/node_modules\ntop_level.txt\n",
|
|
"target": {
|
|
"index": "blah2"
|
|
},
|
|
"node_modules": {
|
|
".DS_Store": "",
|
|
"prettier": {
|
|
"package.json": "{}",
|
|
},
|
|
"package.json": "//package.json"
|
|
},
|
|
"src": {
|
|
".DS_Store": "",
|
|
"foo": {
|
|
"foo.rs": "mod another;\n",
|
|
"another.rs": "// another",
|
|
},
|
|
"bar": {
|
|
"bar.rs": "// bar",
|
|
},
|
|
"lib.rs": "mod foo;\nmod bar;\n",
|
|
},
|
|
"top_level.txt": "top level file",
|
|
".DS_Store": "",
|
|
}));
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.file_scan_exclusions = Some(vec![]);
|
|
settings.project.worktree.file_scan_inclusions = Some(vec![
|
|
"node_modules/**/package.json".to_string(),
|
|
"**/.DS_Store".to_string(),
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
|
|
let tree = Worktree::local(
|
|
dir.path(),
|
|
true,
|
|
Arc::new(RealFs::new(None, cx.executor())),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
tree.flush_fs_events(cx).await;
|
|
tree.read_with(cx, |tree, _| {
|
|
// Assert that file_scan_inclusions overrides file_scan_exclusions.
|
|
check_worktree_entries(
|
|
tree,
|
|
WorktreeExpectations {
|
|
excluded_paths: &[],
|
|
ignored_paths: &["target", "node_modules"],
|
|
tracked_paths: &["src/lib.rs", "src/bar/bar.rs", ".gitignore"],
|
|
included_paths: &[
|
|
"node_modules/prettier/package.json",
|
|
".DS_Store",
|
|
"node_modules/.DS_Store",
|
|
"src/.DS_Store",
|
|
],
|
|
},
|
|
)
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_exclusions_overrules_inclusions(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
cx.executor().allow_parking();
|
|
let dir = TempTree::new(json!({
|
|
".gitignore": "**/target\n/node_modules\n",
|
|
"target": {
|
|
"index": "blah2"
|
|
},
|
|
"node_modules": {
|
|
".DS_Store": "",
|
|
"prettier": {
|
|
"package.json": "{}",
|
|
},
|
|
},
|
|
"src": {
|
|
".DS_Store": "",
|
|
"foo": {
|
|
"foo.rs": "mod another;\n",
|
|
"another.rs": "// another",
|
|
},
|
|
},
|
|
".DS_Store": "",
|
|
}));
|
|
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.file_scan_exclusions =
|
|
Some(vec!["**/.DS_Store".to_string()]);
|
|
settings.project.worktree.file_scan_inclusions =
|
|
Some(vec!["**/.DS_Store".to_string()]);
|
|
});
|
|
});
|
|
});
|
|
|
|
let tree = Worktree::local(
|
|
dir.path(),
|
|
true,
|
|
Arc::new(RealFs::new(None, cx.executor())),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
tree.flush_fs_events(cx).await;
|
|
tree.read_with(cx, |tree, _| {
|
|
// Assert that file_scan_inclusions overrides file_scan_exclusions.
|
|
check_worktree_entries(
|
|
tree,
|
|
WorktreeExpectations {
|
|
excluded_paths: &[".DS_Store, src/.DS_Store"],
|
|
ignored_paths: &["target", "node_modules"],
|
|
tracked_paths: &["src/foo/another.rs", "src/foo/foo.rs", ".gitignore"],
|
|
included_paths: &[],
|
|
},
|
|
)
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_inclusions_reindexes_on_setting_change(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
cx.executor().allow_parking();
|
|
let dir = TempTree::new(json!({
|
|
".gitignore": "**/target\n/node_modules/\n",
|
|
"target": {
|
|
"index": "blah2"
|
|
},
|
|
"node_modules": {
|
|
".DS_Store": "",
|
|
"prettier": {
|
|
"package.json": "{}",
|
|
},
|
|
},
|
|
"src": {
|
|
".DS_Store": "",
|
|
"foo": {
|
|
"foo.rs": "mod another;\n",
|
|
"another.rs": "// another",
|
|
},
|
|
},
|
|
".DS_Store": "",
|
|
}));
|
|
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.file_scan_exclusions = Some(vec![]);
|
|
settings.project.worktree.file_scan_inclusions =
|
|
Some(vec!["node_modules/**".to_string()]);
|
|
});
|
|
});
|
|
});
|
|
let tree = Worktree::local(
|
|
dir.path(),
|
|
true,
|
|
Arc::new(RealFs::new(None, cx.executor())),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
tree.flush_fs_events(cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert!(
|
|
tree.entry_for_path(rel_path("node_modules"))
|
|
.is_some_and(|f| f.is_always_included)
|
|
);
|
|
assert!(
|
|
tree.entry_for_path(rel_path("node_modules/prettier/package.json"))
|
|
.is_some_and(|f| f.is_always_included)
|
|
);
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.file_scan_exclusions = Some(vec![]);
|
|
settings.project.worktree.file_scan_inclusions = Some(vec![]);
|
|
});
|
|
});
|
|
});
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
tree.flush_fs_events(cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert!(
|
|
tree.entry_for_path(rel_path("node_modules"))
|
|
.is_some_and(|f| !f.is_always_included)
|
|
);
|
|
assert!(
|
|
tree.entry_for_path(rel_path("node_modules/prettier/package.json"))
|
|
.is_some_and(|f| !f.is_always_included)
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_exclusions(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
cx.executor().allow_parking();
|
|
let dir = TempTree::new(json!({
|
|
".gitignore": "**/target\n/node_modules\n",
|
|
"target": {
|
|
"index": "blah2"
|
|
},
|
|
"node_modules": {
|
|
".DS_Store": "",
|
|
"prettier": {
|
|
"package.json": "{}",
|
|
},
|
|
},
|
|
"src": {
|
|
".DS_Store": "",
|
|
"foo": {
|
|
"foo.rs": "mod another;\n",
|
|
"another.rs": "// another",
|
|
},
|
|
"bar": {
|
|
"bar.rs": "// bar",
|
|
},
|
|
"lib.rs": "mod foo;\nmod bar;\n",
|
|
},
|
|
".DS_Store": "",
|
|
}));
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.file_scan_exclusions =
|
|
Some(vec!["**/foo/**".to_string(), "**/.DS_Store".to_string()]);
|
|
});
|
|
});
|
|
});
|
|
|
|
let tree = Worktree::local(
|
|
dir.path(),
|
|
true,
|
|
Arc::new(RealFs::new(None, cx.executor())),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
tree.flush_fs_events(cx).await;
|
|
tree.read_with(cx, |tree, _| {
|
|
check_worktree_entries(
|
|
tree,
|
|
WorktreeExpectations {
|
|
excluded_paths: &[
|
|
"src/foo/foo.rs",
|
|
"src/foo/another.rs",
|
|
"node_modules/.DS_Store",
|
|
"src/.DS_Store",
|
|
".DS_Store",
|
|
],
|
|
ignored_paths: &["target", "node_modules"],
|
|
tracked_paths: &["src/lib.rs", "src/bar/bar.rs", ".gitignore"],
|
|
included_paths: &[],
|
|
},
|
|
)
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.file_scan_exclusions =
|
|
Some(vec!["**/node_modules/**".to_string()]);
|
|
});
|
|
});
|
|
});
|
|
tree.flush_fs_events(cx).await;
|
|
cx.executor().run_until_parked();
|
|
tree.read_with(cx, |tree, _| {
|
|
check_worktree_entries(
|
|
tree,
|
|
WorktreeExpectations {
|
|
excluded_paths: &[
|
|
"node_modules/prettier/package.json",
|
|
"node_modules/.DS_Store",
|
|
"node_modules",
|
|
],
|
|
ignored_paths: &["target"],
|
|
tracked_paths: &[
|
|
".gitignore",
|
|
"src/lib.rs",
|
|
"src/bar/bar.rs",
|
|
"src/foo/foo.rs",
|
|
"src/foo/another.rs",
|
|
"src/.DS_Store",
|
|
".DS_Store",
|
|
],
|
|
included_paths: &[],
|
|
},
|
|
)
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_hidden_files(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
cx.executor().allow_parking();
|
|
let dir = TempTree::new(json!({
|
|
".gitignore": "**/target\n",
|
|
".hidden_file": "content",
|
|
".hidden_dir": {
|
|
"nested.rs": "code",
|
|
},
|
|
"src": {
|
|
"visible.rs": "code",
|
|
},
|
|
"logs": {
|
|
"app.log": "logs",
|
|
"debug.log": "logs",
|
|
},
|
|
"visible.txt": "content",
|
|
}));
|
|
|
|
let tree = Worktree::local(
|
|
dir.path(),
|
|
true,
|
|
Arc::new(RealFs::new(None, cx.executor())),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
tree.flush_fs_events(cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_hidden))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path(".gitignore"), true),
|
|
(rel_path(".hidden_dir"), true),
|
|
(rel_path(".hidden_dir/nested.rs"), true),
|
|
(rel_path(".hidden_file"), true),
|
|
(rel_path("logs"), false),
|
|
(rel_path("logs/app.log"), false),
|
|
(rel_path("logs/debug.log"), false),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/visible.rs"), false),
|
|
(rel_path("visible.txt"), false),
|
|
]
|
|
);
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.hidden_files = Some(vec!["**/*.log".to_string()]);
|
|
});
|
|
});
|
|
});
|
|
tree.flush_fs_events(cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_hidden))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path(".gitignore"), false),
|
|
(rel_path(".hidden_dir"), false),
|
|
(rel_path(".hidden_dir/nested.rs"), false),
|
|
(rel_path(".hidden_file"), false),
|
|
(rel_path("logs"), false),
|
|
(rel_path("logs/app.log"), true),
|
|
(rel_path("logs/debug.log"), true),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/visible.rs"), false),
|
|
(rel_path("visible.txt"), false),
|
|
]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
cx.executor().allow_parking();
|
|
let dir = TempTree::new(json!({
|
|
".git": {
|
|
"HEAD": "ref: refs/heads/main\n",
|
|
"foo": "bar",
|
|
},
|
|
".gitignore": "**/target\n/node_modules\ntest_output\n",
|
|
"target": {
|
|
"index": "blah2"
|
|
},
|
|
"node_modules": {
|
|
".DS_Store": "",
|
|
"prettier": {
|
|
"package.json": "{}",
|
|
},
|
|
},
|
|
"src": {
|
|
".DS_Store": "",
|
|
"foo": {
|
|
"foo.rs": "mod another;\n",
|
|
"another.rs": "// another",
|
|
},
|
|
"bar": {
|
|
"bar.rs": "// bar",
|
|
},
|
|
"lib.rs": "mod foo;\nmod bar;\n",
|
|
},
|
|
".DS_Store": "",
|
|
}));
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.file_scan_exclusions = Some(vec![
|
|
"**/.git".to_string(),
|
|
"node_modules/".to_string(),
|
|
"build_output".to_string(),
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
|
|
let tree = Worktree::local(
|
|
dir.path(),
|
|
true,
|
|
Arc::new(RealFs::new(None, cx.executor())),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
tree.flush_fs_events(cx).await;
|
|
tree.read_with(cx, |tree, _| {
|
|
check_worktree_entries(
|
|
tree,
|
|
WorktreeExpectations {
|
|
excluded_paths: &[
|
|
".git/HEAD",
|
|
".git/foo",
|
|
"node_modules",
|
|
"node_modules/.DS_Store",
|
|
"node_modules/prettier",
|
|
"node_modules/prettier/package.json",
|
|
],
|
|
ignored_paths: &["target"],
|
|
tracked_paths: &[
|
|
".DS_Store",
|
|
"src/.DS_Store",
|
|
"src/lib.rs",
|
|
"src/foo/foo.rs",
|
|
"src/foo/another.rs",
|
|
"src/bar/bar.rs",
|
|
".gitignore",
|
|
],
|
|
included_paths: &[],
|
|
},
|
|
)
|
|
});
|
|
|
|
let new_excluded_dir = dir.path().join("build_output");
|
|
let new_ignored_dir = dir.path().join("test_output");
|
|
std::fs::create_dir_all(&new_excluded_dir)
|
|
.unwrap_or_else(|e| panic!("Failed to create a {new_excluded_dir:?} directory: {e}"));
|
|
std::fs::create_dir_all(&new_ignored_dir)
|
|
.unwrap_or_else(|e| panic!("Failed to create a {new_ignored_dir:?} directory: {e}"));
|
|
let node_modules_dir = dir.path().join("node_modules");
|
|
let dot_git_dir = dir.path().join(".git");
|
|
let src_dir = dir.path().join("src");
|
|
for existing_dir in [&node_modules_dir, &dot_git_dir, &src_dir] {
|
|
assert!(
|
|
existing_dir.is_dir(),
|
|
"Expect {existing_dir:?} to be present in the FS already"
|
|
);
|
|
}
|
|
|
|
for directory_for_new_file in [
|
|
new_excluded_dir,
|
|
new_ignored_dir,
|
|
node_modules_dir,
|
|
dot_git_dir,
|
|
src_dir,
|
|
] {
|
|
std::fs::write(directory_for_new_file.join("new_file"), "new file contents")
|
|
.unwrap_or_else(|e| {
|
|
panic!("Failed to create in {directory_for_new_file:?} a new file: {e}")
|
|
});
|
|
}
|
|
tree.flush_fs_events(cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
check_worktree_entries(
|
|
tree,
|
|
WorktreeExpectations {
|
|
excluded_paths: &[
|
|
".git/HEAD",
|
|
".git/foo",
|
|
".git/new_file",
|
|
"node_modules",
|
|
"node_modules/.DS_Store",
|
|
"node_modules/prettier",
|
|
"node_modules/prettier/package.json",
|
|
"node_modules/new_file",
|
|
"build_output",
|
|
"build_output/new_file",
|
|
"test_output/new_file",
|
|
],
|
|
ignored_paths: &["target", "test_output"],
|
|
tracked_paths: &[
|
|
".DS_Store",
|
|
"src/.DS_Store",
|
|
"src/lib.rs",
|
|
"src/foo/foo.rs",
|
|
"src/foo/another.rs",
|
|
"src/bar/bar.rs",
|
|
"src/new_file",
|
|
".gitignore",
|
|
],
|
|
included_paths: &[],
|
|
},
|
|
)
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_fs_events_in_dot_git_worktree(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
cx.executor().allow_parking();
|
|
let dir = TempTree::new(json!({
|
|
".git": {
|
|
"HEAD": "ref: refs/heads/main\n",
|
|
"foo": "foo contents",
|
|
},
|
|
}));
|
|
let dot_git_worktree_dir = dir.path().join(".git");
|
|
|
|
let tree = Worktree::local(
|
|
dot_git_worktree_dir.clone(),
|
|
true,
|
|
Arc::new(RealFs::new(None, cx.executor())),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
tree.flush_fs_events(cx).await;
|
|
tree.read_with(cx, |tree, _| {
|
|
check_worktree_entries(
|
|
tree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &["HEAD", "foo"],
|
|
..Default::default()
|
|
},
|
|
)
|
|
});
|
|
|
|
std::fs::write(dot_git_worktree_dir.join("new_file"), "new file contents")
|
|
.unwrap_or_else(|e| panic!("Failed to create in {dot_git_worktree_dir:?} a new file: {e}"));
|
|
tree.flush_fs_events(cx).await;
|
|
tree.read_with(cx, |tree, _| {
|
|
check_worktree_entries(
|
|
tree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &["HEAD", "foo", "new_file"],
|
|
..Default::default()
|
|
},
|
|
)
|
|
});
|
|
}
|
|
|
|
#[gpui::test(iterations = 30)]
|
|
async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"b": {},
|
|
"c": {},
|
|
"d": {},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = Worktree::local(
|
|
"/root".as_ref(),
|
|
true,
|
|
fs,
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let snapshot1 = tree.update(cx, |tree, cx| {
|
|
let tree = tree.as_local_mut().unwrap();
|
|
let snapshot = Arc::new(Mutex::new(tree.snapshot()));
|
|
tree.observe_updates(0, cx, {
|
|
let snapshot = snapshot.clone();
|
|
let settings = tree.settings();
|
|
move |update| {
|
|
snapshot
|
|
.lock()
|
|
.apply_remote_update(update, &settings.file_scan_inclusions);
|
|
async { true }
|
|
}
|
|
});
|
|
snapshot
|
|
});
|
|
|
|
let entry = tree
|
|
.update(cx, |tree, cx| {
|
|
tree.as_local_mut()
|
|
.unwrap()
|
|
.create_entry(rel_path("a/e").into(), true, None, cx)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.into_included()
|
|
.unwrap();
|
|
assert!(entry.is_dir());
|
|
|
|
cx.executor().run_until_parked();
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("a/e")).unwrap().kind,
|
|
EntryKind::Dir
|
|
);
|
|
});
|
|
|
|
let snapshot2 = tree.update(cx, |tree, _| tree.as_local().unwrap().snapshot());
|
|
assert_eq!(
|
|
snapshot1.lock().entries(true, 0).collect::<Vec<_>>(),
|
|
snapshot2.entries(true, 0).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
cx.executor().allow_parking();
|
|
|
|
let fs_fake = FakeFs::new(cx.background_executor.clone());
|
|
fs_fake
|
|
.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"a": {},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree_fake = Worktree::local(
|
|
"/root".as_ref(),
|
|
true,
|
|
fs_fake,
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let entry = tree_fake
|
|
.update(cx, |tree, cx| {
|
|
tree.as_local_mut().unwrap().create_entry(
|
|
rel_path("a/b/c/d.txt").into(),
|
|
false,
|
|
None,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.into_included()
|
|
.unwrap();
|
|
assert!(entry.is_file());
|
|
|
|
cx.executor().run_until_parked();
|
|
tree_fake.read_with(cx, |tree, _| {
|
|
assert!(
|
|
tree.entry_for_path(rel_path("a/b/c/d.txt"))
|
|
.unwrap()
|
|
.is_file()
|
|
);
|
|
assert!(tree.entry_for_path(rel_path("a/b/c")).unwrap().is_dir());
|
|
assert!(tree.entry_for_path(rel_path("a/b")).unwrap().is_dir());
|
|
});
|
|
|
|
let fs_real = Arc::new(RealFs::new(None, cx.executor()));
|
|
let temp_root = TempTree::new(json!({
|
|
"a": {}
|
|
}));
|
|
|
|
let tree_real = Worktree::local(
|
|
temp_root.path(),
|
|
true,
|
|
fs_real,
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let entry = tree_real
|
|
.update(cx, |tree, cx| {
|
|
tree.as_local_mut().unwrap().create_entry(
|
|
rel_path("a/b/c/d.txt").into(),
|
|
false,
|
|
None,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.into_included()
|
|
.unwrap();
|
|
assert!(entry.is_file());
|
|
|
|
cx.executor().run_until_parked();
|
|
tree_real.read_with(cx, |tree, _| {
|
|
assert!(
|
|
tree.entry_for_path(rel_path("a/b/c/d.txt"))
|
|
.unwrap()
|
|
.is_file()
|
|
);
|
|
assert!(tree.entry_for_path(rel_path("a/b/c")).unwrap().is_dir());
|
|
assert!(tree.entry_for_path(rel_path("a/b")).unwrap().is_dir());
|
|
});
|
|
|
|
// Test smallest change
|
|
let entry = tree_real
|
|
.update(cx, |tree, cx| {
|
|
tree.as_local_mut().unwrap().create_entry(
|
|
rel_path("a/b/c/e.txt").into(),
|
|
false,
|
|
None,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.into_included()
|
|
.unwrap();
|
|
assert!(entry.is_file());
|
|
|
|
cx.executor().run_until_parked();
|
|
tree_real.read_with(cx, |tree, _| {
|
|
assert!(
|
|
tree.entry_for_path(rel_path("a/b/c/e.txt"))
|
|
.unwrap()
|
|
.is_file()
|
|
);
|
|
});
|
|
|
|
// Test largest change
|
|
let entry = tree_real
|
|
.update(cx, |tree, cx| {
|
|
tree.as_local_mut().unwrap().create_entry(
|
|
rel_path("d/e/f/g.txt").into(),
|
|
false,
|
|
None,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.into_included()
|
|
.unwrap();
|
|
assert!(entry.is_file());
|
|
|
|
cx.executor().run_until_parked();
|
|
tree_real.read_with(cx, |tree, _| {
|
|
assert!(
|
|
tree.entry_for_path(rel_path("d/e/f/g.txt"))
|
|
.unwrap()
|
|
.is_file()
|
|
);
|
|
assert!(tree.entry_for_path(rel_path("d/e/f")).unwrap().is_dir());
|
|
assert!(tree.entry_for_path(rel_path("d/e")).unwrap().is_dir());
|
|
assert!(tree.entry_for_path(rel_path("d")).unwrap().is_dir());
|
|
});
|
|
}
|
|
|
|
#[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(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&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(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&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,
|
|
mut rng: StdRng,
|
|
) {
|
|
init_test(cx);
|
|
let operations = env::var("OPERATIONS")
|
|
.map(|o| o.parse().unwrap())
|
|
.unwrap_or(5);
|
|
let initial_entries = env::var("INITIAL_ENTRIES")
|
|
.map(|o| o.parse().unwrap())
|
|
.unwrap_or(20);
|
|
|
|
let root_dir = Path::new(path!("/test"));
|
|
let fs = FakeFs::new(cx.background_executor.clone()) as Arc<dyn Fs>;
|
|
fs.as_fake().insert_tree(root_dir, json!({})).await;
|
|
for _ in 0..initial_entries {
|
|
randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await;
|
|
}
|
|
log::info!("generated initial tree");
|
|
|
|
let worktree = Worktree::local(
|
|
root_dir,
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut snapshots = vec![worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot())];
|
|
let updates = Arc::new(Mutex::new(Vec::new()));
|
|
worktree.update(cx, |tree, cx| {
|
|
check_worktree_change_events(tree, cx);
|
|
|
|
tree.as_local_mut().unwrap().observe_updates(0, cx, {
|
|
let updates = updates.clone();
|
|
move |update| {
|
|
updates.lock().push(update);
|
|
async { true }
|
|
}
|
|
});
|
|
});
|
|
|
|
for _ in 0..operations {
|
|
worktree
|
|
.update(cx, |worktree, cx| {
|
|
randomly_mutate_worktree(worktree, &mut rng, cx)
|
|
})
|
|
.await
|
|
.log_err();
|
|
worktree.read_with(cx, |tree, _| {
|
|
tree.as_local().unwrap().snapshot().check_invariants(true)
|
|
});
|
|
|
|
if rng.random_bool(0.6) {
|
|
snapshots.push(worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()));
|
|
}
|
|
}
|
|
|
|
worktree
|
|
.update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete())
|
|
.await;
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
let final_snapshot = worktree.read_with(cx, |tree, _| {
|
|
let tree = tree.as_local().unwrap();
|
|
let snapshot = tree.snapshot();
|
|
snapshot.check_invariants(true);
|
|
snapshot
|
|
});
|
|
|
|
let settings = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().settings());
|
|
|
|
for (i, snapshot) in snapshots.into_iter().enumerate().rev() {
|
|
let mut updated_snapshot = snapshot.clone();
|
|
for update in updates.lock().iter() {
|
|
if update.scan_id >= updated_snapshot.scan_id() as u64 {
|
|
updated_snapshot
|
|
.apply_remote_update(update.clone(), &settings.file_scan_inclusions);
|
|
}
|
|
}
|
|
|
|
assert_eq!(
|
|
updated_snapshot.entries(true, 0).collect::<Vec<_>>(),
|
|
final_snapshot.entries(true, 0).collect::<Vec<_>>(),
|
|
"wrong updates after snapshot {i}: {updates:#?}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[gpui::test(iterations = 100)]
|
|
async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) {
|
|
init_test(cx);
|
|
let operations = env::var("OPERATIONS")
|
|
.map(|o| o.parse().unwrap())
|
|
.unwrap_or(40);
|
|
let initial_entries = env::var("INITIAL_ENTRIES")
|
|
.map(|o| o.parse().unwrap())
|
|
.unwrap_or(20);
|
|
|
|
let root_dir = Path::new(path!("/test"));
|
|
let fs = FakeFs::new(cx.background_executor.clone()) as Arc<dyn Fs>;
|
|
fs.as_fake().insert_tree(root_dir, json!({})).await;
|
|
for _ in 0..initial_entries {
|
|
randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await;
|
|
}
|
|
log::info!("generated initial tree");
|
|
|
|
let worktree = Worktree::local(
|
|
root_dir,
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let updates = Arc::new(Mutex::new(Vec::new()));
|
|
worktree.update(cx, |tree, cx| {
|
|
check_worktree_change_events(tree, cx);
|
|
|
|
tree.as_local_mut().unwrap().observe_updates(0, cx, {
|
|
let updates = updates.clone();
|
|
move |update| {
|
|
updates.lock().push(update);
|
|
async { true }
|
|
}
|
|
});
|
|
});
|
|
|
|
worktree
|
|
.update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete())
|
|
.await;
|
|
|
|
fs.as_fake().pause_events();
|
|
let mut snapshots = Vec::new();
|
|
let mut mutations_len = operations;
|
|
while mutations_len > 1 {
|
|
if rng.random_bool(0.2) {
|
|
worktree
|
|
.update(cx, |worktree, cx| {
|
|
randomly_mutate_worktree(worktree, &mut rng, cx)
|
|
})
|
|
.await
|
|
.log_err();
|
|
} else {
|
|
randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await;
|
|
}
|
|
|
|
let buffered_event_count = fs.as_fake().buffered_event_count();
|
|
if buffered_event_count > 0 && rng.random_bool(0.3) {
|
|
let len = rng.random_range(0..=buffered_event_count);
|
|
log::info!("flushing {} events", len);
|
|
fs.as_fake().flush_events(len);
|
|
} else {
|
|
randomly_mutate_fs(&fs, root_dir, 0.6, &mut rng).await;
|
|
mutations_len -= 1;
|
|
}
|
|
|
|
cx.executor().run_until_parked();
|
|
if rng.random_bool(0.2) {
|
|
log::info!("storing snapshot {}", snapshots.len());
|
|
let snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot());
|
|
snapshots.push(snapshot);
|
|
}
|
|
}
|
|
|
|
log::info!("quiescing");
|
|
fs.as_fake().flush_events(usize::MAX);
|
|
cx.executor().run_until_parked();
|
|
|
|
let snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot());
|
|
snapshot.check_invariants(true);
|
|
let expanded_paths = snapshot
|
|
.expanded_entries()
|
|
.map(|e| e.path.clone())
|
|
.collect::<Vec<_>>();
|
|
|
|
{
|
|
let new_worktree = Worktree::local(
|
|
root_dir,
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
new_worktree
|
|
.update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete())
|
|
.await;
|
|
new_worktree
|
|
.update(cx, |tree, _| {
|
|
tree.as_local_mut()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(expanded_paths)
|
|
})
|
|
.recv()
|
|
.await;
|
|
let new_snapshot =
|
|
new_worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot());
|
|
assert_eq!(
|
|
snapshot.entries_without_ids(true),
|
|
new_snapshot.entries_without_ids(true)
|
|
);
|
|
}
|
|
|
|
let settings = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().settings());
|
|
|
|
for (i, mut prev_snapshot) in snapshots.into_iter().enumerate().rev() {
|
|
for update in updates.lock().iter() {
|
|
if update.scan_id >= prev_snapshot.scan_id() as u64 {
|
|
prev_snapshot.apply_remote_update(update.clone(), &settings.file_scan_inclusions);
|
|
}
|
|
}
|
|
|
|
assert_eq!(
|
|
prev_snapshot
|
|
.entries(true, 0)
|
|
.map(ignore_pending_dir)
|
|
.collect::<Vec<_>>(),
|
|
snapshot
|
|
.entries(true, 0)
|
|
.map(ignore_pending_dir)
|
|
.collect::<Vec<_>>(),
|
|
"wrong updates after snapshot {i}: {updates:#?}",
|
|
);
|
|
}
|
|
|
|
fn ignore_pending_dir(entry: &Entry) -> Entry {
|
|
let mut entry = entry.clone();
|
|
if entry.kind.is_dir() {
|
|
entry.kind = EntryKind::Dir
|
|
}
|
|
entry
|
|
}
|
|
}
|
|
|
|
// The worktree's `UpdatedEntries` event can be used to follow along with
|
|
// all changes to the worktree's snapshot.
|
|
fn check_worktree_change_events(tree: &mut Worktree, cx: &mut Context<Worktree>) {
|
|
let mut entries = tree.entries(true, 0).cloned().collect::<Vec<_>>();
|
|
cx.subscribe(&cx.entity(), move |tree, _, event, _| {
|
|
if let Event::UpdatedEntries(changes) = event {
|
|
for (path, _, change_type) in changes.iter() {
|
|
let entry = tree.entry_for_path(path).cloned();
|
|
let ix = match entries.binary_search_by_key(&path, |e| &e.path) {
|
|
Ok(ix) | Err(ix) => ix,
|
|
};
|
|
match change_type {
|
|
PathChange::Added => entries.insert(ix, entry.unwrap()),
|
|
PathChange::Removed => drop(entries.remove(ix)),
|
|
PathChange::Updated => {
|
|
let entry = entry.unwrap();
|
|
let existing_entry = entries.get_mut(ix).unwrap();
|
|
assert_eq!(existing_entry.path, entry.path);
|
|
*existing_entry = entry;
|
|
}
|
|
PathChange::AddedOrUpdated | PathChange::Loaded => {
|
|
let entry = entry.unwrap();
|
|
if entries.get(ix).map(|e| &e.path) == Some(&entry.path) {
|
|
*entries.get_mut(ix).unwrap() = entry;
|
|
} else {
|
|
entries.insert(ix, entry);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let new_entries = tree.entries(true, 0).cloned().collect::<Vec<_>>();
|
|
assert_eq!(entries, new_entries, "incorrect changes: {:?}", changes);
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn randomly_mutate_worktree(
|
|
worktree: &mut Worktree,
|
|
rng: &mut impl Rng,
|
|
cx: &mut Context<Worktree>,
|
|
) -> Task<Result<()>> {
|
|
log::info!("mutating worktree");
|
|
let worktree = worktree.as_local_mut().unwrap();
|
|
let snapshot = worktree.snapshot();
|
|
let entry = snapshot.entries(false, 0).choose(rng).unwrap();
|
|
|
|
match rng.random_range(0_u32..100) {
|
|
0..=33 if entry.path.as_ref() != RelPath::empty() => {
|
|
log::info!("deleting entry {:?} ({})", entry.path, entry.id.to_usize());
|
|
let task = worktree
|
|
.delete_entry(entry.id, false, cx)
|
|
.unwrap_or_else(|| Task::ready(Ok(None)));
|
|
|
|
cx.background_spawn(async move {
|
|
task.await?;
|
|
Ok(())
|
|
})
|
|
}
|
|
_ => {
|
|
if entry.is_dir() {
|
|
let child_path = entry.path.join(rel_path(&random_filename(rng)));
|
|
let is_dir = rng.random_bool(0.3);
|
|
log::info!(
|
|
"creating {} at {:?}",
|
|
if is_dir { "dir" } else { "file" },
|
|
child_path,
|
|
);
|
|
let task = worktree.create_entry(child_path, is_dir, None, cx);
|
|
cx.background_spawn(async move {
|
|
task.await?;
|
|
Ok(())
|
|
})
|
|
} else {
|
|
log::info!(
|
|
"overwriting file {:?} ({})",
|
|
&entry.path,
|
|
entry.id.to_usize()
|
|
);
|
|
let task = worktree.write_file(
|
|
entry.path.clone(),
|
|
"".into(),
|
|
Default::default(),
|
|
encoding_rs::UTF_8,
|
|
false,
|
|
cx,
|
|
);
|
|
cx.background_spawn(async move {
|
|
task.await?;
|
|
Ok(())
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn randomly_mutate_fs(
|
|
fs: &Arc<dyn Fs>,
|
|
root_path: &Path,
|
|
insertion_probability: f64,
|
|
rng: &mut impl Rng,
|
|
) {
|
|
log::info!("mutating fs");
|
|
let mut files = Vec::new();
|
|
let mut dirs = Vec::new();
|
|
for path in fs.as_fake().paths(false) {
|
|
if path.starts_with(root_path) {
|
|
if fs.is_file(&path).await {
|
|
files.push(path);
|
|
} else {
|
|
dirs.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (files.is_empty() && dirs.len() == 1) || rng.random_bool(insertion_probability) {
|
|
let path = dirs.choose(rng).unwrap();
|
|
let new_path = path.join(random_filename(rng));
|
|
|
|
if rng.random() {
|
|
log::info!(
|
|
"creating dir {:?}",
|
|
new_path.strip_prefix(root_path).unwrap()
|
|
);
|
|
fs.create_dir(&new_path).await.unwrap();
|
|
} else {
|
|
log::info!(
|
|
"creating file {:?}",
|
|
new_path.strip_prefix(root_path).unwrap()
|
|
);
|
|
fs.create_file(&new_path, Default::default()).await.unwrap();
|
|
}
|
|
} else if rng.random_bool(0.05) {
|
|
let ignore_dir_path = dirs.choose(rng).unwrap();
|
|
let ignore_path = ignore_dir_path.join(GITIGNORE);
|
|
|
|
let subdirs = dirs
|
|
.iter()
|
|
.filter(|d| d.starts_with(ignore_dir_path))
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
let subfiles = files
|
|
.iter()
|
|
.filter(|d| d.starts_with(ignore_dir_path))
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
let files_to_ignore = {
|
|
let len = rng.random_range(0..=subfiles.len());
|
|
subfiles.choose_multiple(rng, len)
|
|
};
|
|
let dirs_to_ignore = {
|
|
let len = rng.random_range(0..subdirs.len());
|
|
subdirs.choose_multiple(rng, len)
|
|
};
|
|
|
|
let mut ignore_contents = String::new();
|
|
for path_to_ignore in files_to_ignore.chain(dirs_to_ignore) {
|
|
writeln!(
|
|
ignore_contents,
|
|
"{}",
|
|
path_to_ignore
|
|
.strip_prefix(ignore_dir_path)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap()
|
|
)
|
|
.unwrap();
|
|
}
|
|
log::info!(
|
|
"creating gitignore {:?} with contents:\n{}",
|
|
ignore_path.strip_prefix(root_path).unwrap(),
|
|
ignore_contents
|
|
);
|
|
fs.save(
|
|
&ignore_path,
|
|
&ignore_contents.as_str().into(),
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
} else {
|
|
let old_path = {
|
|
let file_path = files.choose(rng);
|
|
let dir_path = dirs[1..].choose(rng);
|
|
file_path.into_iter().chain(dir_path).choose(rng).unwrap()
|
|
};
|
|
|
|
let is_rename = rng.random();
|
|
if is_rename {
|
|
let new_path_parent = dirs
|
|
.iter()
|
|
.filter(|d| !d.starts_with(old_path))
|
|
.choose(rng)
|
|
.unwrap();
|
|
|
|
let overwrite_existing_dir =
|
|
!old_path.starts_with(new_path_parent) && rng.random_bool(0.3);
|
|
let new_path = if overwrite_existing_dir {
|
|
fs.remove_dir(
|
|
new_path_parent,
|
|
RemoveOptions {
|
|
recursive: true,
|
|
ignore_if_not_exists: true,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
new_path_parent.to_path_buf()
|
|
} else {
|
|
new_path_parent.join(random_filename(rng))
|
|
};
|
|
|
|
log::info!(
|
|
"renaming {:?} to {}{:?}",
|
|
old_path.strip_prefix(root_path).unwrap(),
|
|
if overwrite_existing_dir {
|
|
"overwrite "
|
|
} else {
|
|
""
|
|
},
|
|
new_path.strip_prefix(root_path).unwrap()
|
|
);
|
|
fs.rename(
|
|
old_path,
|
|
&new_path,
|
|
fs::RenameOptions {
|
|
overwrite: true,
|
|
ignore_if_exists: true,
|
|
create_parents: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
} else if fs.is_file(old_path).await {
|
|
log::info!(
|
|
"deleting file {:?}",
|
|
old_path.strip_prefix(root_path).unwrap()
|
|
);
|
|
fs.remove_file(old_path, Default::default()).await.unwrap();
|
|
} else {
|
|
log::info!(
|
|
"deleting dir {:?}",
|
|
old_path.strip_prefix(root_path).unwrap()
|
|
);
|
|
fs.remove_dir(
|
|
old_path,
|
|
RemoveOptions {
|
|
recursive: true,
|
|
ignore_if_not_exists: true,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn random_filename(rng: &mut impl Rng) -> String {
|
|
(0..6)
|
|
.map(|_| rng.sample(rand::distr::Alphanumeric))
|
|
.map(char::from)
|
|
.collect()
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_private_single_file_worktree(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree("/", json!({".env": "PRIVATE=secret\n"}))
|
|
.await;
|
|
let tree = Worktree::local(
|
|
Path::new("/.env"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
tree.read_with(cx, |tree, _| {
|
|
let entry = tree.entry_for_path(rel_path("")).unwrap();
|
|
assert!(entry.is_private);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repository_above_root(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(executor);
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
".git": {},
|
|
"subproject": {
|
|
"a.txt": "A"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
let worktree = Worktree::local(
|
|
path!("/root/subproject").as_ref(),
|
|
true,
|
|
fs.clone(),
|
|
Arc::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().scan_complete()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
let repos = worktree.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().repositories()
|
|
});
|
|
pretty_assertions::assert_eq!(repos, [Path::new(path!("/root")).into()]);
|
|
|
|
fs.touch_path(path!("/root/subproject")).await;
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().scan_complete()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
let repos = worktree.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().repositories()
|
|
});
|
|
pretty_assertions::assert_eq!(repos, [Path::new(path!("/root")).into()]);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_global_gitignore(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let home = paths::home_dir();
|
|
let fs = FakeFs::new(executor);
|
|
fs.insert_tree(
|
|
home,
|
|
json!({
|
|
".config": {
|
|
"git": {
|
|
"ignore": "foo\n/bar\nbaz\n"
|
|
}
|
|
},
|
|
"project": {
|
|
".git": {},
|
|
".gitignore": "!baz",
|
|
"foo": "",
|
|
"bar": "",
|
|
"sub": {
|
|
"bar": "",
|
|
},
|
|
"subrepo": {
|
|
".git": {},
|
|
"bar": ""
|
|
},
|
|
"baz": ""
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
let worktree = Worktree::local(
|
|
home.join("project"),
|
|
true,
|
|
fs.clone(),
|
|
Arc::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().scan_complete()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
// .gitignore overrides excludesFile, and anchored paths in excludesFile are resolved
|
|
// relative to the nearest containing repository
|
|
worktree.update(cx, |worktree, _cx| {
|
|
check_worktree_entries(
|
|
worktree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &["foo", "bar", "subrepo/bar"],
|
|
tracked_paths: &["sub/bar", "baz"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
|
|
// Ignore statuses are updated when excludesFile changes
|
|
fs.write(
|
|
&home.join(".config").join("git").join("ignore"),
|
|
"/bar\nbaz\n".as_bytes(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().scan_complete()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
worktree.update(cx, |worktree, _cx| {
|
|
check_worktree_entries(
|
|
worktree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &["bar", "subrepo/bar"],
|
|
tracked_paths: &["foo", "sub/bar", "baz"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
|
|
// Statuses are updated when .git added/removed
|
|
fs.remove_dir(
|
|
&home.join("project").join("subrepo").join(".git"),
|
|
RemoveOptions {
|
|
recursive: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().scan_complete()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
worktree.update(cx, |worktree, _cx| {
|
|
check_worktree_entries(
|
|
worktree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &["bar"],
|
|
tracked_paths: &["foo", "sub/bar", "baz", "subrepo/bar"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repo_exclude_in_worktree(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(executor);
|
|
|
|
fs.insert_tree(
|
|
path!("/repo"),
|
|
json!({
|
|
".git": {
|
|
"info": {
|
|
"exclude": ".env.*"
|
|
},
|
|
"worktrees": {
|
|
"my-worktree": {
|
|
"commondir": "../.."
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.insert_tree(
|
|
path!("/worktree"),
|
|
json!({
|
|
// .git is pointing to the repo
|
|
".git": "gitdir: /repo/.git/worktrees/my-worktree",
|
|
".env.local": "secret=1234",
|
|
"not-ignored.txt": "",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let worktree = Worktree::local(
|
|
path!("/worktree").as_ref(),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().scan_complete()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
// .env.local should be ignored via info/exclude from the repo's exclude
|
|
worktree.update(cx, |worktree, _cx| {
|
|
check_worktree_entries(
|
|
worktree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &[".env.local"],
|
|
tracked_paths: &["not-ignored.txt"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repo_exclude(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(executor);
|
|
let project_dir = Path::new(path!("/project"));
|
|
fs.insert_tree(
|
|
project_dir,
|
|
json!({
|
|
".git": {
|
|
"info": {
|
|
"exclude": ".env.*"
|
|
}
|
|
},
|
|
".env.example": "secret=xxxx",
|
|
".env.local": "secret=1234",
|
|
".gitignore": "!.env.example",
|
|
"README.md": "# Repo Exclude",
|
|
"src": {
|
|
"main.rs": "fn main() {}",
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let worktree = Worktree::local(
|
|
project_dir,
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().scan_complete()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
// .gitignore overrides .git/info/exclude
|
|
worktree.update(cx, |worktree, _cx| {
|
|
check_worktree_entries(
|
|
worktree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &[".env.local"],
|
|
tracked_paths: &[".env.example", "README.md", "src/main.rs"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
|
|
// Ignore statuses are updated when .git/info/exclude file changes
|
|
fs.write(
|
|
&project_dir.join(DOT_GIT).join(REPO_EXCLUDE),
|
|
".env.example".as_bytes(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().scan_complete()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
worktree.update(cx, |worktree, _cx| {
|
|
check_worktree_entries(
|
|
worktree,
|
|
WorktreeExpectations {
|
|
tracked_paths: &[".env.example", ".env.local", "README.md", "src/main.rs"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repo_exclude_anchored_pattern(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(executor);
|
|
let project_dir = Path::new(path!("/project"));
|
|
fs.insert_tree(
|
|
project_dir,
|
|
json!({
|
|
".git": {
|
|
"info": {
|
|
"exclude": "vendor/cache"
|
|
}
|
|
},
|
|
"vendor": {
|
|
"cache": {
|
|
"blob.bin": "",
|
|
},
|
|
"keep.txt": "",
|
|
},
|
|
"elsewhere": {
|
|
"vendor": {
|
|
"cache": {
|
|
"blob.bin": "",
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let worktree = Worktree::local(
|
|
project_dir,
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().scan_complete()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
// An anchored pattern (containing a `/`) is matched relative to the work
|
|
// tree root, so only the top-level `vendor/cache` is ignored.
|
|
worktree.update(cx, |worktree, _cx| {
|
|
check_worktree_entries(
|
|
worktree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &["vendor/cache"],
|
|
tracked_paths: &["vendor/keep.txt", "elsewhere/vendor/cache"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct WorktreeExpectations {
|
|
excluded_paths: &'static [&'static str],
|
|
ignored_paths: &'static [&'static str],
|
|
tracked_paths: &'static [&'static str],
|
|
included_paths: &'static [&'static str],
|
|
}
|
|
|
|
#[track_caller]
|
|
fn check_worktree_entries(tree: &Worktree, expectations: WorktreeExpectations) {
|
|
for path in expectations.excluded_paths {
|
|
let entry = tree.entry_for_path(rel_path(path));
|
|
assert!(
|
|
entry.is_none(),
|
|
"expected path '{path}' to be excluded, but got entry: {entry:?}",
|
|
);
|
|
}
|
|
for path in expectations.ignored_paths {
|
|
let entry = tree
|
|
.entry_for_path(rel_path(path))
|
|
.unwrap_or_else(|| panic!("Missing entry for expected ignored path '{path}'"));
|
|
assert!(
|
|
entry.is_ignored,
|
|
"expected path '{path}' to be ignored, but got entry: {entry:?}",
|
|
);
|
|
}
|
|
for path in expectations.tracked_paths {
|
|
let entry = tree
|
|
.entry_for_path(rel_path(path))
|
|
.unwrap_or_else(|| panic!("Missing entry for expected tracked path '{path}'"));
|
|
assert!(
|
|
!entry.is_ignored || entry.is_always_included,
|
|
"expected path '{path}' to be tracked, but got entry: {entry:?}",
|
|
);
|
|
}
|
|
for path in expectations.included_paths {
|
|
let entry = tree
|
|
.entry_for_path(rel_path(path))
|
|
.unwrap_or_else(|| panic!("Missing entry for expected included path '{path}'"));
|
|
assert!(
|
|
entry.is_always_included,
|
|
"expected path '{path}' to always be included, but got entry: {entry:?}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_root_repo_common_dir_for_relative_gitdir(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(executor);
|
|
fs.insert_tree(
|
|
path!("/repo"),
|
|
json!({
|
|
".git": {
|
|
"HEAD": "ref: refs/heads/main",
|
|
"config": "[core]\n\tbare = false\n",
|
|
"info": {
|
|
"exclude": "ignored.txt\n",
|
|
},
|
|
"worktrees": {
|
|
"feature-a": {
|
|
"HEAD": "ref: refs/heads/feature-a",
|
|
"commondir": "../..",
|
|
"gitdir": "/repo/feature-a/.git",
|
|
},
|
|
},
|
|
},
|
|
"feature-a": {
|
|
".git": "gitdir: ../.git/worktrees/feature-a",
|
|
"file.txt": "content",
|
|
"ignored.txt": "ignored",
|
|
"subdir": {
|
|
"file.txt": "content",
|
|
"ignored.txt": "ignored",
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let feature_tree = Worktree::local(
|
|
path!("/repo/feature-a").as_ref(),
|
|
true,
|
|
fs.clone(),
|
|
Arc::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
feature_tree
|
|
.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
feature_tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.snapshot()
|
|
.root_repo_common_dir()
|
|
.map(|path| path.as_ref()),
|
|
Some(Path::new(path!("/repo/.git"))),
|
|
);
|
|
check_worktree_entries(
|
|
tree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &["ignored.txt"],
|
|
tracked_paths: &["file.txt"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
|
|
let nested_tree = Worktree::local(
|
|
path!("/repo/feature-a/subdir").as_ref(),
|
|
true,
|
|
fs.clone(),
|
|
Arc::default(),
|
|
true,
|
|
WorktreeId::from_proto(1),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
nested_tree
|
|
.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
nested_tree.read_with(cx, |tree, _| {
|
|
check_worktree_entries(
|
|
tree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &["ignored.txt"],
|
|
tracked_paths: &["file.txt"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
|
|
fs.write(
|
|
Path::new(path!("/repo/.git")).join(REPO_EXCLUDE).as_ref(),
|
|
"file.txt\n".as_bytes(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
feature_tree.read_with(cx, |tree, _| {
|
|
check_worktree_entries(
|
|
tree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &["file.txt", "subdir/file.txt"],
|
|
tracked_paths: &["ignored.txt", "subdir/ignored.txt"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
nested_tree.read_with(cx, |tree, _| {
|
|
check_worktree_entries(
|
|
tree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &["file.txt"],
|
|
tracked_paths: &["ignored.txt"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_root_repo_common_dir(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
use git::repository::Worktree as GitWorktree;
|
|
|
|
let fs = FakeFs::new(executor);
|
|
|
|
// Set up a main repo and a linked worktree pointing back to it.
|
|
fs.insert_tree(
|
|
path!("/main_repo"),
|
|
json!({
|
|
".git": {},
|
|
"file.txt": "content",
|
|
}),
|
|
)
|
|
.await;
|
|
fs.add_linked_worktree_for_repo(
|
|
Path::new(path!("/main_repo/.git")),
|
|
false,
|
|
GitWorktree {
|
|
path: PathBuf::from(path!("/linked_worktree")),
|
|
ref_name: Some("refs/heads/feature".into()),
|
|
sha: "abc123".into(),
|
|
is_main: false,
|
|
is_bare: false,
|
|
},
|
|
)
|
|
.await;
|
|
fs.write(
|
|
path!("/linked_worktree/file.txt").as_ref(),
|
|
"content".as_bytes(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
path!("/linked_worktree").as_ref(),
|
|
true,
|
|
fs.clone(),
|
|
Arc::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
// For a linked worktree, root_repo_common_dir should point to the
|
|
// main repo's .git, not the worktree-specific git directory.
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.snapshot().root_repo_common_dir().map(|p| p.as_ref()),
|
|
Some(Path::new(path!("/main_repo/.git"))),
|
|
);
|
|
});
|
|
|
|
let event_count: Rc<Cell<usize>> = Rc::new(Cell::new(0));
|
|
tree.update(cx, {
|
|
let event_count = event_count.clone();
|
|
|_, cx| {
|
|
cx.subscribe(&cx.entity(), move |_, _, event, _| {
|
|
if matches!(event, Event::UpdatedRootRepoCommonDir { .. }) {
|
|
event_count.set(event_count.get() + 1);
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
});
|
|
|
|
// Remove .git — root_repo_common_dir should become None.
|
|
fs.remove_file(
|
|
&PathBuf::from(path!("/linked_worktree/.git")),
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tree.flush_fs_events(cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(tree.snapshot().root_repo_common_dir(), None);
|
|
});
|
|
assert_eq!(
|
|
event_count.get(),
|
|
1,
|
|
"should have emitted UpdatedRootRepoCommonDir on removal"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_invisible_worktree_does_not_track_ancestor_git_repository(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(executor);
|
|
fs.insert_tree(
|
|
path!("/repo"),
|
|
json!({
|
|
".git": {},
|
|
"project": {
|
|
"file.txt": "content",
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let worktree = Worktree::local(
|
|
path!("/repo/project").as_ref(),
|
|
false,
|
|
fs.clone(),
|
|
Arc::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().scan_complete()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
worktree.read_with(cx, |worktree, _| {
|
|
let local_worktree = worktree.as_local().unwrap();
|
|
assert!(local_worktree.repositories().is_empty());
|
|
assert_eq!(local_worktree.root_repo_common_dir(), None);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_linked_worktree_gitfile_event_preserves_repo(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
// Regression test: in a linked worktree, `.git` is a file (containing
|
|
// "gitdir: ..."), not a directory. When the background scanner receives
|
|
// a filesystem event for a path inside the main repo's `.git` directory
|
|
// (which it watches via the commondir), the ancestor-walking code in
|
|
// `process_events` calls `is_git_dir` on each ancestor. If `is_git_dir`
|
|
// treats `.git` files the same as `.git` directories, it incorrectly
|
|
// identifies the gitfile as a git dir, adds it to `dot_git_abs_paths`,
|
|
// and `update_git_repositories` panics because the path is outside the
|
|
// worktree root.
|
|
init_test(cx);
|
|
use git::repository::Worktree as GitWorktree;
|
|
|
|
let fs = FakeFs::new(executor);
|
|
fs.insert_tree(path!("/main_repo"), json!({ ".git": {}, "file.txt": "" }))
|
|
.await;
|
|
fs.add_linked_worktree_for_repo(
|
|
Path::new(path!("/main_repo/.git")),
|
|
false,
|
|
GitWorktree {
|
|
path: PathBuf::from(path!("/linked_worktree")),
|
|
ref_name: Some("refs/heads/feature".into()),
|
|
sha: "abc123".into(),
|
|
is_main: false,
|
|
is_bare: false,
|
|
},
|
|
)
|
|
.await;
|
|
fs.write(path!("/linked_worktree/file.txt").as_ref(), b"content")
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
path!("/linked_worktree").as_ref(),
|
|
true,
|
|
fs.clone(),
|
|
Arc::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
// Overwrite the .git gitfile with garbage to trigger an event for the
|
|
// gitfile path itself, which only matches `dot_git_abs_path`.
|
|
fs.write(path!("/linked_worktree/.git").as_ref(), b"garbage")
|
|
.await
|
|
.unwrap();
|
|
tree.flush_fs_events(cx).await;
|
|
|
|
// The worktree should still be intact.
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.snapshot().root_repo_common_dir().map(|p| p.as_ref()),
|
|
Some(Path::new(path!("/main_repo/.git"))),
|
|
"linked worktree repo should survive a gitfile change event"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_shared_common_dir_event_updates_all_repositories(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
// A main checkout and one of its linked worktrees can both live inside the
|
|
// same project worktree, sharing a common git directory. An event in that
|
|
// common directory (e.g. a ref update) must refresh every repository that
|
|
// reads from it, not just the first match.
|
|
init_test(cx);
|
|
|
|
use git::repository::Worktree as GitWorktree;
|
|
|
|
let fs = FakeFs::new(executor.clone());
|
|
fs.insert_tree(
|
|
path!("/project"),
|
|
json!({
|
|
"main_repo": {
|
|
".git": {},
|
|
"file.txt": "content",
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
fs.add_linked_worktree_for_repo(
|
|
Path::new(path!("/project/main_repo/.git")),
|
|
false,
|
|
GitWorktree {
|
|
path: PathBuf::from(path!("/project/linked")),
|
|
ref_name: Some("refs/heads/feature".into()),
|
|
sha: "abc123".into(),
|
|
is_main: false,
|
|
is_bare: false,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let tree = Worktree::local(
|
|
path!("/project").as_ref(),
|
|
true,
|
|
fs.clone(),
|
|
Arc::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
let mut events = cx.events(&tree);
|
|
fs.emit_fs_event(
|
|
path!("/project/main_repo/.git/refs/heads/main"),
|
|
Some(PathEventKind::Changed),
|
|
);
|
|
executor.run_until_parked();
|
|
|
|
let mut updated_work_dirs = Vec::new();
|
|
while let Ok(event) = events.try_recv() {
|
|
if let Event::UpdatedGitRepositories(updates) = event {
|
|
updated_work_dirs.extend(
|
|
updates
|
|
.iter()
|
|
.filter_map(|update| update.new_work_directory_abs_path.clone()),
|
|
);
|
|
}
|
|
}
|
|
updated_work_dirs.sort();
|
|
assert_eq!(
|
|
updated_work_dirs,
|
|
[
|
|
Arc::from(Path::new(path!("/project/linked"))),
|
|
Arc::from(Path::new(path!("/project/main_repo"))),
|
|
],
|
|
"a ref update in the shared common dir should refresh both repositories"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_noisy_dot_git_events_do_not_emit_git_repo_update(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
// Events for object database writes, hook files, lock files, and the
|
|
// reflogs of HEAD/branches/remote-tracking branches carry no git state
|
|
// changes that Zed cares about beyond what the accompanying ref or index
|
|
// events already convey, so they must not trigger a git metadata rescan.
|
|
// The stash reflog and ref updates themselves must still trigger one.
|
|
//
|
|
init_test(cx);
|
|
|
|
use git::repository::Worktree as GitWorktree;
|
|
|
|
let fs = FakeFs::new(executor);
|
|
|
|
fs.insert_tree(
|
|
path!("/main_repo"),
|
|
json!({
|
|
".git": {},
|
|
"file.txt": "content",
|
|
}),
|
|
)
|
|
.await;
|
|
fs.add_linked_worktree_for_repo(
|
|
Path::new(path!("/main_repo/.git")),
|
|
false,
|
|
GitWorktree {
|
|
path: PathBuf::from(path!("/linked_worktree")),
|
|
ref_name: Some("refs/heads/feature".into()),
|
|
sha: "abc123".into(),
|
|
is_main: false,
|
|
is_bare: false,
|
|
},
|
|
)
|
|
.await;
|
|
fs.write(
|
|
path!("/linked_worktree/file.txt").as_ref(),
|
|
"content".as_bytes(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
path!("/linked_worktree").as_ref(),
|
|
true,
|
|
fs.clone(),
|
|
Arc::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
let repo_update_count: Rc<Cell<usize>> = Rc::new(Cell::new(0));
|
|
tree.update(cx, {
|
|
let repo_update_count = repo_update_count.clone();
|
|
|_, cx| {
|
|
cx.subscribe(&cx.entity(), move |_, _, event, _| {
|
|
if matches!(event, Event::UpdatedGitRepositories(_)) {
|
|
repo_update_count.set(repo_update_count.get() + 1);
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
});
|
|
|
|
let skipped_paths = [
|
|
// Standard common git dir skipped paths
|
|
path!("/main_repo/.git/objects/aa/bbccddee"),
|
|
path!("/main_repo/.git/objects/pack/pack-1234.pack"),
|
|
path!("/main_repo/.git/hooks/pre-commit"),
|
|
path!("/main_repo/.git/logs/HEAD"),
|
|
path!("/main_repo/.git/logs/refs/heads/main"),
|
|
path!("/main_repo/.git/logs/refs/remotes/origin/main"),
|
|
path!("/main_repo/.git/logs/refs/tags/v1.0"),
|
|
path!("/main_repo/.git/rebase-merge/done"),
|
|
path!("/main_repo/.git/rebase-apply/onto"),
|
|
path!("/main_repo/.git/sequencer/todo"),
|
|
path!("/main_repo/.git/index.lock"),
|
|
path!("/main_repo/.git/refs/heads/main.lock"),
|
|
path!("/main_repo/.git/COMMIT_EDITMSG"),
|
|
path!("/main_repo/.git/packed-refs.new"),
|
|
path!("/main_repo/.git/config.new"),
|
|
path!("/main_repo/.git/index.new"),
|
|
path!("/main_repo/.git/index-abc123.tmp"),
|
|
path!("/main_repo/.git/FETCH_HEAD"),
|
|
path!("/main_repo/.git/ORIG_HEAD"),
|
|
path!("/main_repo/.git/BISECT_LOG"),
|
|
path!("/main_repo/.git/info/refs"),
|
|
path!("/main_repo/.git/info/refs_lzOf51"),
|
|
path!("/main_repo/.git/gc.pid"),
|
|
// Linked-worktree specific skipped paths
|
|
path!("/main_repo/.git/worktrees/feature/index.lock"),
|
|
];
|
|
for path in skipped_paths {
|
|
fs.emit_fs_event(path, Some(PathEventKind::Changed));
|
|
cx.run_until_parked();
|
|
assert_eq!(
|
|
repo_update_count.get(),
|
|
0,
|
|
"event for {path} should not emit UpdatedGitRepositories"
|
|
);
|
|
}
|
|
|
|
let rescan_paths = [
|
|
// Standard common git dir rescan paths
|
|
path!("/main_repo/.git/logs/refs/stash"),
|
|
path!("/main_repo/.git/refs/heads/main"),
|
|
path!("/main_repo/.git/info/exclude"),
|
|
path!("/main_repo/.git/refs/heads/branch.new"),
|
|
path!("/main_repo/.git/refs/heads/branch.tmp"),
|
|
// Linked-worktree worktree-specific rescan paths
|
|
path!("/main_repo/.git/worktrees/feature/index"),
|
|
path!("/main_repo/.git/worktrees/feature/HEAD"),
|
|
];
|
|
for path in rescan_paths {
|
|
let count_before = repo_update_count.get();
|
|
fs.emit_fs_event(path, Some(PathEventKind::Changed));
|
|
cx.run_until_parked();
|
|
assert!(
|
|
repo_update_count.get() > count_before,
|
|
"event for {path} should emit UpdatedGitRepositories"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_linked_worktree_event_in_unregistered_common_git_dir_does_not_panic(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
// Regression test: a rescan event on a linked worktree's commondir
|
|
// must not panic when the worktree's repository has already been
|
|
// unregistered from `git_repositories`.
|
|
init_test(cx);
|
|
|
|
use git::repository::Worktree as GitWorktree;
|
|
|
|
let fs = FakeFs::new(executor);
|
|
|
|
fs.insert_tree(
|
|
path!("/main_repo"),
|
|
json!({
|
|
".git": {},
|
|
"file.txt": "content",
|
|
}),
|
|
)
|
|
.await;
|
|
fs.add_linked_worktree_for_repo(
|
|
Path::new(path!("/main_repo/.git")),
|
|
false,
|
|
GitWorktree {
|
|
path: PathBuf::from(path!("/linked_worktree")),
|
|
ref_name: Some("refs/heads/feature".into()),
|
|
sha: "abc123".into(),
|
|
is_main: false,
|
|
is_bare: false,
|
|
},
|
|
)
|
|
.await;
|
|
fs.write(
|
|
path!("/linked_worktree/file.txt").as_ref(),
|
|
"content".as_bytes(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
path!("/linked_worktree").as_ref(),
|
|
true,
|
|
fs.clone(),
|
|
Arc::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
// Unregister the linked worktree's repository by removing its gitfile.
|
|
fs.remove_file(
|
|
Path::new(path!("/linked_worktree/.git")),
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tree.flush_fs_events(cx).await;
|
|
|
|
// Deliver the kind of Rescan event `FsWatcher` emits when the kernel
|
|
// signals `need_rescan` for the commondir.
|
|
fs.emit_fs_event(path!("/main_repo/.git"), Some(fs::PathEventKind::Rescan));
|
|
cx.run_until_parked();
|
|
tree.flush_fs_events(cx).await;
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_dot_git_dir_event_does_not_suppress_children(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
// On Windows, modifying a file inside .git causes ReadDirectoryChangesW to also emit
|
|
// a Modify event for the .git directory itself (because its last-write timestamp changes).
|
|
// When these events arrive in the same batch, a naive ancestor-based dedup would collapse
|
|
// all child events into the .git directory event, losing the information about which
|
|
// specific files changed. This test verifies that the git-related event processing happens
|
|
// before the dedup, so that meaningful .git child events still trigger UpdatedGitRepositories.
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(executor.clone());
|
|
let project_dir = Path::new(path!("/project"));
|
|
fs.insert_tree(
|
|
project_dir,
|
|
json!({
|
|
".git": {},
|
|
"src": {
|
|
"main.rs": "fn main() {}",
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let worktree = Worktree::local(
|
|
project_dir,
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().scan_complete()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
let dot_git = project_dir.join(DOT_GIT);
|
|
|
|
// Case 1: Events for .git AND .git/index.lock should NOT emit UpdatedGitRepositories
|
|
// (index.lock is in the skipped files list)
|
|
{
|
|
let mut events = cx.events(&worktree);
|
|
fs.pause_events();
|
|
fs.emit_fs_event(dot_git.clone(), Some(PathEventKind::Changed));
|
|
fs.emit_fs_event(dot_git.join("index.lock"), Some(PathEventKind::Created));
|
|
fs.unpause_events_and_flush();
|
|
executor.run_until_parked();
|
|
|
|
let got_git_update = drain_git_repo_updates(&mut events);
|
|
assert!(
|
|
!got_git_update,
|
|
"should NOT emit UpdatedGitRepositories when .git batch only contains index.lock"
|
|
);
|
|
}
|
|
|
|
// Case 2: Event for just .git (bare directory event) should NOT emit UpdatedGitRepositories
|
|
{
|
|
let mut events = cx.events(&worktree);
|
|
fs.pause_events();
|
|
fs.emit_fs_event(dot_git.clone(), Some(PathEventKind::Changed));
|
|
fs.unpause_events_and_flush();
|
|
executor.run_until_parked();
|
|
|
|
let got_git_update = drain_git_repo_updates(&mut events);
|
|
assert!(
|
|
!got_git_update,
|
|
"should NOT emit UpdatedGitRepositories for a bare .git directory event"
|
|
);
|
|
}
|
|
|
|
// Case 3: Events for .git AND .git/index should emit UpdatedGitRepositories
|
|
{
|
|
let mut events = cx.events(&worktree);
|
|
fs.pause_events();
|
|
fs.emit_fs_event(dot_git.clone(), Some(PathEventKind::Changed));
|
|
fs.emit_fs_event(dot_git.join("index"), Some(PathEventKind::Changed));
|
|
fs.unpause_events_and_flush();
|
|
executor.run_until_parked();
|
|
|
|
let got_git_update = drain_git_repo_updates(&mut events);
|
|
assert!(
|
|
got_git_update,
|
|
"should emit UpdatedGitRepositories when .git batch contains index"
|
|
);
|
|
}
|
|
|
|
// Case 4: Event for .git/index only should emit UpdatedGitRepositories
|
|
{
|
|
let mut events = cx.events(&worktree);
|
|
fs.pause_events();
|
|
fs.emit_fs_event(dot_git.join("index"), Some(PathEventKind::Changed));
|
|
fs.unpause_events_and_flush();
|
|
executor.run_until_parked();
|
|
|
|
let got_git_update = drain_git_repo_updates(&mut events);
|
|
assert!(
|
|
got_git_update,
|
|
"should emit UpdatedGitRepositories for a .git/index event"
|
|
);
|
|
}
|
|
|
|
{
|
|
let mut events = cx.events(&worktree);
|
|
fs.pause_events();
|
|
fs.emit_fs_event(dot_git, Some(PathEventKind::Rescan));
|
|
fs.unpause_events_and_flush();
|
|
executor.run_until_parked();
|
|
|
|
let got_git_update = drain_git_repo_updates(&mut events);
|
|
assert!(
|
|
got_git_update,
|
|
"should emit UpdatedGitRepositories for a .git rescan event"
|
|
);
|
|
}
|
|
|
|
{
|
|
let mut events = cx.events(&worktree);
|
|
fs.pause_events();
|
|
fs.emit_fs_event(project_dir, Some(PathEventKind::Rescan));
|
|
fs.unpause_events_and_flush();
|
|
executor.run_until_parked();
|
|
|
|
let got_git_update = drain_git_repo_updates(&mut events);
|
|
assert!(
|
|
got_git_update,
|
|
"should emit UpdatedGitRepositories for a .git rescan event"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_ref_updates_in_dot_git_subdirectories_are_detected(cx: &mut TestAppContext) {
|
|
// On Linux and FreeBSD the native file watcher is non-recursive: watching `.git`
|
|
// does not deliver events for files nested below it, like the loose refs that git
|
|
// updates on commit, fetch, and branch operations. The worktree must watch the
|
|
// `refs` tree explicitly, including directories created after the initial scan.
|
|
init_test(cx);
|
|
cx.executor().allow_parking();
|
|
|
|
let dir = TempTree::new(json!({
|
|
".git": {},
|
|
"a.txt": "a-contents",
|
|
}));
|
|
std::fs::write(
|
|
dir.path().join(".git/refs/heads/main"),
|
|
"0000000000000000000000000000000000000000\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
dir.path(),
|
|
true,
|
|
Arc::new(RealFs::new(None, cx.executor())),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
tree.flush_fs_events(cx).await;
|
|
|
|
let mut events = cx.events(&tree);
|
|
std::fs::write(
|
|
dir.path().join(".git/refs/heads/main"),
|
|
"1111111111111111111111111111111111111111\n",
|
|
)
|
|
.unwrap();
|
|
expect_git_repo_update(&mut events, cx, "updating a loose ref").await;
|
|
|
|
std::fs::create_dir_all(dir.path().join(".git/refs/remotes/origin")).unwrap();
|
|
expect_git_repo_update(&mut events, cx, "creating a directory under refs").await;
|
|
tree.flush_fs_events(cx).await;
|
|
drain_git_repo_updates(&mut events);
|
|
|
|
std::fs::write(
|
|
dir.path().join(".git/refs/remotes/origin/main"),
|
|
"2222222222222222222222222222222222222222\n",
|
|
)
|
|
.unwrap();
|
|
expect_git_repo_update(
|
|
&mut events,
|
|
cx,
|
|
"updating a ref in a directory created after the initial scan",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn expect_git_repo_update(
|
|
events: &mut futures::channel::mpsc::UnboundedReceiver<Event>,
|
|
cx: &mut TestAppContext,
|
|
description: &str,
|
|
) {
|
|
let mut elapsed = std::time::Duration::ZERO;
|
|
let timeout = std::time::Duration::from_secs(10);
|
|
let poll_interval = std::time::Duration::from_millis(50);
|
|
loop {
|
|
match events.try_recv() {
|
|
Ok(Event::UpdatedGitRepositories(_)) => return,
|
|
Ok(_) => continue,
|
|
Err(_) => {}
|
|
}
|
|
assert!(
|
|
elapsed < timeout,
|
|
"timed out waiting for UpdatedGitRepositories after {description}"
|
|
);
|
|
cx.background_executor.timer(poll_interval).await;
|
|
elapsed += poll_interval;
|
|
}
|
|
}
|
|
|
|
fn drain_git_repo_updates(events: &mut futures::channel::mpsc::UnboundedReceiver<Event>) -> bool {
|
|
let mut found = false;
|
|
while let Ok(event) = events.try_recv() {
|
|
if matches!(event, Event::UpdatedGitRepositories(_)) {
|
|
found = true;
|
|
}
|
|
}
|
|
found
|
|
}
|
|
|
|
fn init_test(cx: &mut gpui::TestAppContext) {
|
|
zlog::init_test();
|
|
|
|
cx.update(|cx| {
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
});
|
|
}
|
|
|
|
async fn wait_for_condition(
|
|
cx: &mut TestAppContext,
|
|
mut condition: impl FnMut(&mut TestAppContext) -> bool,
|
|
) {
|
|
for _ in 0..50 {
|
|
if condition(cx) {
|
|
return;
|
|
}
|
|
cx.executor().run_until_parked();
|
|
cx.background_executor
|
|
.timer(std::time::Duration::from_millis(10))
|
|
.await;
|
|
}
|
|
panic!("timed out waiting for test condition");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_load_file_encoding(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
struct TestCase {
|
|
name: &'static str,
|
|
bytes: Vec<u8>,
|
|
expected_text: &'static str,
|
|
}
|
|
|
|
// --- Success Cases ---
|
|
let success_cases = vec![
|
|
TestCase {
|
|
name: "utf8.txt",
|
|
bytes: "こんにちは".as_bytes().to_vec(),
|
|
expected_text: "こんにちは",
|
|
},
|
|
TestCase {
|
|
name: "sjis.txt",
|
|
bytes: vec![0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd],
|
|
expected_text: "こんにちは",
|
|
},
|
|
TestCase {
|
|
name: "eucjp.txt",
|
|
bytes: vec![0xa4, 0xb3, 0xa4, 0xf3, 0xa4, 0xcb, 0xa4, 0xc1, 0xa4, 0xcf],
|
|
expected_text: "こんにちは",
|
|
},
|
|
TestCase {
|
|
name: "iso2022jp.txt",
|
|
bytes: vec![
|
|
0x1b, 0x24, 0x42, 0x24, 0x33, 0x24, 0x73, 0x24, 0x4b, 0x24, 0x41, 0x24, 0x4f, 0x1b,
|
|
0x28, 0x42,
|
|
],
|
|
expected_text: "こんにちは",
|
|
},
|
|
TestCase {
|
|
name: "win1252.txt",
|
|
bytes: vec![0x43, 0x61, 0x66, 0xe9],
|
|
expected_text: "Café",
|
|
},
|
|
TestCase {
|
|
name: "gbk.txt",
|
|
bytes: vec![
|
|
0xbd, 0xf1, 0xcc, 0xec, 0xcc, 0xec, 0xc6, 0xf8, 0xb2, 0xbb, 0xb4, 0xed,
|
|
],
|
|
expected_text: "今天天气不错",
|
|
},
|
|
// UTF-16LE with BOM
|
|
TestCase {
|
|
name: "utf16le_bom.txt",
|
|
bytes: vec![
|
|
0xFF, 0xFE, // BOM
|
|
0x53, 0x30, 0x93, 0x30, 0x6B, 0x30, 0x61, 0x30, 0x6F, 0x30,
|
|
],
|
|
expected_text: "こんにちは",
|
|
},
|
|
// UTF-16BE with BOM
|
|
TestCase {
|
|
name: "utf16be_bom.txt",
|
|
bytes: vec![
|
|
0xFE, 0xFF, // BOM
|
|
0x30, 0x53, 0x30, 0x93, 0x30, 0x6B, 0x30, 0x61, 0x30, 0x6F,
|
|
],
|
|
expected_text: "こんにちは",
|
|
},
|
|
// UTF-16LE without BOM (ASCII only)
|
|
// This relies on the "null byte heuristic" we implemented.
|
|
// "ABC" -> 41 00 42 00 43 00
|
|
TestCase {
|
|
name: "utf16le_ascii_no_bom.txt",
|
|
bytes: vec![0x41, 0x00, 0x42, 0x00, 0x43, 0x00],
|
|
expected_text: "ABC",
|
|
},
|
|
];
|
|
|
|
// --- Failure Cases ---
|
|
let failure_cases = vec![
|
|
// Binary File (Should be detected by heuristic and return Error)
|
|
// Contains random bytes and mixed nulls that don't match UTF-16 patterns
|
|
TestCase {
|
|
name: "binary.bin",
|
|
bytes: vec![0x00, 0xFF, 0x12, 0x00, 0x99, 0x88, 0x77, 0x66, 0x00],
|
|
expected_text: "", // Not used
|
|
},
|
|
];
|
|
|
|
let root_path = if cfg!(windows) {
|
|
Path::new("C:\\root")
|
|
} else {
|
|
Path::new("/root")
|
|
};
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.create_dir(root_path).await.unwrap();
|
|
|
|
for case in success_cases.iter().chain(failure_cases.iter()) {
|
|
let path = root_path.join(case.name);
|
|
fs.write(&path, &case.bytes).await.unwrap();
|
|
}
|
|
|
|
let tree = Worktree::local(
|
|
root_path,
|
|
true,
|
|
fs,
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
let rel_path = |name: &str| {
|
|
RelPath::new(&Path::new(name), PathStyle::local())
|
|
.unwrap()
|
|
.into_arc()
|
|
};
|
|
|
|
// Run Success Tests
|
|
for case in success_cases {
|
|
let loaded = tree
|
|
.update(cx, |tree, cx| tree.load_file(&rel_path(case.name), cx))
|
|
.await;
|
|
if let Err(e) = &loaded {
|
|
panic!("Failed to load success case '{}': {:?}", case.name, e);
|
|
}
|
|
let loaded = loaded.unwrap();
|
|
assert_eq!(
|
|
loaded.text, case.expected_text,
|
|
"Encoding mismatch for file: {}",
|
|
case.name
|
|
);
|
|
}
|
|
|
|
// Run Failure Tests
|
|
for case in failure_cases {
|
|
let loaded = tree
|
|
.update(cx, |tree, cx| tree.load_file(&rel_path(case.name), cx))
|
|
.await;
|
|
assert!(
|
|
loaded.is_err(),
|
|
"Failure case '{}' unexpectedly succeeded! It should have been detected as binary.",
|
|
case.name
|
|
);
|
|
let err_msg = loaded.unwrap_err().to_string();
|
|
println!("Got expected error for {}: {}", case.name, err_msg);
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_write_file_encoding(cx: &mut gpui::TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
|
|
let root_path = if cfg!(windows) {
|
|
Path::new("C:\\root")
|
|
} else {
|
|
Path::new("/root")
|
|
};
|
|
fs.create_dir(root_path).await.unwrap();
|
|
|
|
let worktree = Worktree::local(
|
|
root_path,
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Define test case structure
|
|
struct TestCase {
|
|
name: &'static str,
|
|
text: &'static str,
|
|
encoding: &'static encoding_rs::Encoding,
|
|
has_bom: bool,
|
|
expected_bytes: Vec<u8>,
|
|
}
|
|
|
|
let cases = vec![
|
|
// Shift_JIS with Japanese
|
|
TestCase {
|
|
name: "Shift_JIS with Japanese",
|
|
text: "こんにちは",
|
|
encoding: encoding_rs::SHIFT_JIS,
|
|
has_bom: false,
|
|
expected_bytes: vec![0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd],
|
|
},
|
|
// UTF-8 No BOM
|
|
TestCase {
|
|
name: "UTF-8 No BOM",
|
|
text: "AB",
|
|
encoding: encoding_rs::UTF_8,
|
|
has_bom: false,
|
|
expected_bytes: vec![0x41, 0x42],
|
|
},
|
|
// UTF-8 with BOM
|
|
TestCase {
|
|
name: "UTF-8 with BOM",
|
|
text: "AB",
|
|
encoding: encoding_rs::UTF_8,
|
|
has_bom: true,
|
|
expected_bytes: vec![0xEF, 0xBB, 0xBF, 0x41, 0x42],
|
|
},
|
|
// UTF-16LE No BOM with Japanese
|
|
// NOTE: This passes thanks to the manual encoding fix implemented in `write_file`.
|
|
TestCase {
|
|
name: "UTF-16LE No BOM with Japanese",
|
|
text: "こんにちは",
|
|
encoding: encoding_rs::UTF_16LE,
|
|
has_bom: false,
|
|
expected_bytes: vec![0x53, 0x30, 0x93, 0x30, 0x6b, 0x30, 0x61, 0x30, 0x6f, 0x30],
|
|
},
|
|
// UTF-16LE with BOM
|
|
TestCase {
|
|
name: "UTF-16LE with BOM",
|
|
text: "A",
|
|
encoding: encoding_rs::UTF_16LE,
|
|
has_bom: true,
|
|
expected_bytes: vec![0xFF, 0xFE, 0x41, 0x00],
|
|
},
|
|
// UTF-16BE No BOM with Japanese
|
|
// NOTE: This passes thanks to the manual encoding fix.
|
|
TestCase {
|
|
name: "UTF-16BE No BOM with Japanese",
|
|
text: "こんにちは",
|
|
encoding: encoding_rs::UTF_16BE,
|
|
has_bom: false,
|
|
expected_bytes: vec![0x30, 0x53, 0x30, 0x93, 0x30, 0x6b, 0x30, 0x61, 0x30, 0x6f],
|
|
},
|
|
// UTF-16BE with BOM
|
|
TestCase {
|
|
name: "UTF-16BE with BOM",
|
|
text: "A",
|
|
encoding: encoding_rs::UTF_16BE,
|
|
has_bom: true,
|
|
expected_bytes: vec![0xFE, 0xFF, 0x00, 0x41],
|
|
},
|
|
];
|
|
|
|
for (i, case) in cases.into_iter().enumerate() {
|
|
let file_name = format!("test_{}.txt", i);
|
|
let path: Arc<Path> = Path::new(&file_name).into();
|
|
let file_path = root_path.join(&file_name);
|
|
|
|
fs.insert_file(&file_path, "".into()).await;
|
|
|
|
let rel_path = RelPath::new(&path, PathStyle::local()).unwrap().into_arc();
|
|
let text = text::Rope::from(case.text);
|
|
|
|
let task = worktree.update(cx, |wt, cx| {
|
|
wt.write_file(
|
|
rel_path,
|
|
text,
|
|
text::LineEnding::Unix,
|
|
case.encoding,
|
|
case.has_bom,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
if let Err(e) = task.await {
|
|
panic!("Unexpected error in case '{}': {:?}", case.name, e);
|
|
}
|
|
|
|
let bytes = fs.load_bytes(&file_path).await.unwrap();
|
|
|
|
assert_eq!(
|
|
bytes, case.expected_bytes,
|
|
"case '{}' mismatch. Expected {:?}, but got {:?}",
|
|
case.name, case.expected_bytes, bytes
|
|
);
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_refresh_entries_for_paths_creates_ancestors(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"a": {
|
|
"b": {
|
|
"c": {
|
|
"deep_file.txt": "content",
|
|
"sibling.txt": "content"
|
|
},
|
|
"d": {
|
|
"under_sibling_dir.txt": "content"
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
false, // Disable scanning so the initial scan doesn't discover any entries
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|e| e.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
&[rel_path("")],
|
|
"Only root entry should exist when scanning is disabled"
|
|
);
|
|
|
|
assert!(tree.entry_for_path(rel_path("a")).is_none());
|
|
assert!(tree.entry_for_path(rel_path("a/b")).is_none());
|
|
assert!(tree.entry_for_path(rel_path("a/b/c")).is_none());
|
|
assert!(
|
|
tree.entry_for_path(rel_path("a/b/c/deep_file.txt"))
|
|
.is_none()
|
|
);
|
|
});
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("a/b/c/deep_file.txt").into()])
|
|
})
|
|
.recv()
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|e| e.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
&[
|
|
rel_path(""),
|
|
rel_path("a"),
|
|
rel_path("a/b"),
|
|
rel_path("a/b/c"),
|
|
rel_path("a/b/c/deep_file.txt"),
|
|
rel_path("a/b/c/sibling.txt"),
|
|
rel_path("a/b/d"),
|
|
],
|
|
"All ancestors should be created when refreshing a deeply nested path"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_single_file_worktree_deleted(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"test.txt": "content",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root/test.txt"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert!(tree.is_single_file(), "Should be a single-file worktree");
|
|
assert_eq!(tree.abs_path().as_ref(), Path::new("/root/test.txt"));
|
|
});
|
|
|
|
// Delete the file
|
|
fs.remove_file(Path::new("/root/test.txt"), Default::default())
|
|
.await
|
|
.unwrap();
|
|
|
|
// Subscribe to worktree events
|
|
let deleted_event_received = Rc::new(Cell::new(false));
|
|
let _subscription = cx.update({
|
|
let deleted_event_received = deleted_event_received.clone();
|
|
|cx| {
|
|
cx.subscribe(&tree, move |_, event, _| {
|
|
if matches!(event, Event::Deleted) {
|
|
deleted_event_received.set(true);
|
|
}
|
|
})
|
|
}
|
|
});
|
|
|
|
// Trigger filesystem events - the scanner should detect the file is gone immediately
|
|
// and emit a Deleted event
|
|
cx.background_executor.run_until_parked();
|
|
cx.background_executor
|
|
.advance_clock(std::time::Duration::from_secs(1));
|
|
cx.background_executor.run_until_parked();
|
|
|
|
assert!(
|
|
deleted_event_received.get(),
|
|
"Should receive Deleted event when single-file worktree root is deleted"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_remote_worktree_without_git_emits_root_repo_event_after_first_update(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
cx.update(|cx| {
|
|
let store = SettingsStore::test(cx);
|
|
cx.set_global(store);
|
|
});
|
|
|
|
let client = AnyProtoClient::new(NoopProtoClient::new());
|
|
|
|
let worktree = cx.update(|cx| {
|
|
Worktree::remote(
|
|
1,
|
|
clock::ReplicaId::new(1),
|
|
proto::WorktreeMetadata {
|
|
id: 1,
|
|
root_name: "project".to_string(),
|
|
visible: true,
|
|
abs_path: "/home/user/project".to_string(),
|
|
root_repo_common_dir: None,
|
|
root_repo_is_linked_worktree: false,
|
|
},
|
|
client,
|
|
PathStyle::Posix,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
let events: Arc<std::sync::Mutex<Vec<&'static str>>> =
|
|
Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let events_clone = events.clone();
|
|
cx.update(|cx| {
|
|
cx.subscribe(&worktree, move |_, event, _cx| {
|
|
if matches!(event, Event::UpdatedRootRepoCommonDir { .. }) {
|
|
events_clone
|
|
.lock()
|
|
.unwrap()
|
|
.push("UpdatedRootRepoCommonDir");
|
|
}
|
|
if matches!(event, Event::UpdatedEntries(_)) {
|
|
events_clone.lock().unwrap().push("UpdatedEntries");
|
|
}
|
|
})
|
|
.detach();
|
|
});
|
|
|
|
// Send an update with entries but no repo info (plain directory).
|
|
worktree.update(cx, |worktree, _cx| {
|
|
worktree
|
|
.as_remote()
|
|
.unwrap()
|
|
.update_from_remote(proto::UpdateWorktree {
|
|
project_id: 1,
|
|
worktree_id: 1,
|
|
abs_path: "/home/user/project".to_string(),
|
|
root_name: "project".to_string(),
|
|
updated_entries: vec![proto::Entry {
|
|
id: 1,
|
|
is_dir: true,
|
|
path: "".to_string(),
|
|
inode: 1,
|
|
mtime: Some(proto::Timestamp {
|
|
seconds: 0,
|
|
nanos: 0,
|
|
}),
|
|
is_ignored: false,
|
|
is_hidden: false,
|
|
is_external: false,
|
|
is_fifo: false,
|
|
size: None,
|
|
canonical_path: None,
|
|
}],
|
|
removed_entries: vec![],
|
|
scan_id: 1,
|
|
is_last_update: true,
|
|
updated_repositories: vec![],
|
|
removed_repositories: vec![],
|
|
root_repo_common_dir: None,
|
|
root_repo_is_linked_worktree: false,
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
let fired = events.lock().unwrap();
|
|
assert!(
|
|
fired.contains(&"UpdatedEntries"),
|
|
"UpdatedEntries should fire after remote update"
|
|
);
|
|
assert!(
|
|
fired.contains(&"UpdatedRootRepoCommonDir"),
|
|
"UpdatedRootRepoCommonDir should fire after first remote update even when \
|
|
root_repo_common_dir is None, to signal that repo state is now known"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_remote_worktree_with_git_emits_root_repo_event_when_repo_info_arrives(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
cx.update(|cx| {
|
|
let store = SettingsStore::test(cx);
|
|
cx.set_global(store);
|
|
});
|
|
|
|
let client = AnyProtoClient::new(NoopProtoClient::new());
|
|
|
|
let worktree = cx.update(|cx| {
|
|
Worktree::remote(
|
|
1,
|
|
clock::ReplicaId::new(1),
|
|
proto::WorktreeMetadata {
|
|
id: 1,
|
|
root_name: "project".to_string(),
|
|
visible: true,
|
|
abs_path: "/home/user/project".to_string(),
|
|
root_repo_common_dir: None,
|
|
root_repo_is_linked_worktree: false,
|
|
},
|
|
client,
|
|
PathStyle::Posix,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
let events: Arc<std::sync::Mutex<Vec<&'static str>>> =
|
|
Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let events_clone = events.clone();
|
|
cx.update(|cx| {
|
|
cx.subscribe(&worktree, move |_, event, _cx| {
|
|
if matches!(event, Event::UpdatedRootRepoCommonDir { .. }) {
|
|
events_clone
|
|
.lock()
|
|
.unwrap()
|
|
.push("UpdatedRootRepoCommonDir");
|
|
}
|
|
})
|
|
.detach();
|
|
});
|
|
|
|
// Send an update where repo info arrives (None -> Some).
|
|
worktree.update(cx, |worktree, _cx| {
|
|
worktree
|
|
.as_remote()
|
|
.unwrap()
|
|
.update_from_remote(proto::UpdateWorktree {
|
|
project_id: 1,
|
|
worktree_id: 1,
|
|
abs_path: "/home/user/project".to_string(),
|
|
root_name: "project".to_string(),
|
|
updated_entries: vec![proto::Entry {
|
|
id: 1,
|
|
is_dir: true,
|
|
path: "".to_string(),
|
|
inode: 1,
|
|
mtime: Some(proto::Timestamp {
|
|
seconds: 0,
|
|
nanos: 0,
|
|
}),
|
|
is_ignored: false,
|
|
is_hidden: false,
|
|
is_external: false,
|
|
is_fifo: false,
|
|
size: None,
|
|
canonical_path: None,
|
|
}],
|
|
removed_entries: vec![],
|
|
scan_id: 1,
|
|
is_last_update: true,
|
|
updated_repositories: vec![],
|
|
removed_repositories: vec![],
|
|
root_repo_common_dir: Some("/home/user/project/.git".to_string()),
|
|
root_repo_is_linked_worktree: false,
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
let fired = events.lock().unwrap();
|
|
assert!(
|
|
fired.contains(&"UpdatedRootRepoCommonDir"),
|
|
"UpdatedRootRepoCommonDir should fire when repo info arrives (None -> Some)"
|
|
);
|
|
assert_eq!(
|
|
fired
|
|
.iter()
|
|
.filter(|e| **e == "UpdatedRootRepoCommonDir")
|
|
.count(),
|
|
1,
|
|
"should fire exactly once, not duplicate"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_remote_worktree_root_repo_metadata_cleared_only_by_completed_scan(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
cx.update(|cx| {
|
|
let store = SettingsStore::test(cx);
|
|
cx.set_global(store);
|
|
});
|
|
|
|
let client = AnyProtoClient::new(NoopProtoClient::new());
|
|
|
|
// Metadata eagerly seeds the root repo info, as `AddWorktreeResponse` /
|
|
// `WorktreeMetadata` do before the host's scan completes.
|
|
let worktree = cx.update(|cx| {
|
|
Worktree::remote(
|
|
1,
|
|
clock::ReplicaId::new(1),
|
|
proto::WorktreeMetadata {
|
|
id: 1,
|
|
root_name: "feature-a".to_string(),
|
|
visible: true,
|
|
abs_path: "/home/user/monty/feature-a".to_string(),
|
|
root_repo_common_dir: Some("/home/user/monty/.bare".to_string()),
|
|
root_repo_is_linked_worktree: true,
|
|
},
|
|
client,
|
|
PathStyle::Posix,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
let root_repo_metadata = |cx: &mut TestAppContext| {
|
|
worktree.read_with(cx, |worktree, _| {
|
|
let snapshot = worktree.snapshot();
|
|
(
|
|
snapshot.root_repo_common_dir().cloned(),
|
|
snapshot.root_repo_is_linked_worktree(),
|
|
)
|
|
})
|
|
};
|
|
|
|
let update = |scan_id: u64, is_last_update: bool| proto::UpdateWorktree {
|
|
project_id: 1,
|
|
worktree_id: 1,
|
|
abs_path: "/home/user/monty/feature-a".to_string(),
|
|
root_name: "feature-a".to_string(),
|
|
updated_entries: vec![],
|
|
removed_entries: vec![],
|
|
scan_id,
|
|
is_last_update,
|
|
updated_repositories: vec![],
|
|
removed_repositories: vec![],
|
|
root_repo_common_dir: None,
|
|
root_repo_is_linked_worktree: false,
|
|
};
|
|
|
|
// A mid-scan update without repo info must not clobber the seeded
|
|
// metadata: the sender's scanner may not have registered the repo yet.
|
|
worktree.update(cx, |worktree, _cx| {
|
|
worktree
|
|
.as_remote()
|
|
.unwrap()
|
|
.update_from_remote(update(2, false));
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
assert_eq!(
|
|
root_repo_metadata(cx),
|
|
(Some(Arc::from(Path::new("/home/user/monty/.bare"))), true,),
|
|
"mid-scan update without repo info should not clear seeded metadata"
|
|
);
|
|
|
|
// A completed scan without repo info is authoritative: the repo is gone.
|
|
worktree.update(cx, |worktree, _cx| {
|
|
worktree
|
|
.as_remote()
|
|
.unwrap()
|
|
.update_from_remote(update(3, true));
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
assert_eq!(
|
|
root_repo_metadata(cx),
|
|
(None, false),
|
|
"completed scan without repo info should clear root repo metadata"
|
|
);
|
|
}
|
|
|
|
// Regression test: a remote worktree used to emit `UpdatedEntries` with an
|
|
// empty changeset (`Arc::default()`), discarding the changed paths. Consumers
|
|
// that key off those paths - notably the agent's `.agents/skills` refresh -
|
|
// therefore never fired on remote projects, so skills pasted into an already
|
|
// open project were never picked up. The changeset must carry the real paths.
|
|
//
|
|
// This drives the real host -> remote pipeline: a `FakeFs`-backed local
|
|
// worktree scans the filesystem and produces `UpdateWorktree` messages via
|
|
// `observe_updates`, which we relay into a remote worktree exactly as the
|
|
// collab server does.
|
|
#[gpui::test]
|
|
async fn test_remote_worktree_update_entries_carry_changed_paths(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
".agents": {
|
|
"skills": {}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// The host worktree scans the fake filesystem and broadcasts updates.
|
|
let host = Worktree::local(
|
|
path!("/root").as_ref(),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(1),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.read(|cx| host.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
// The remote worktree receives those updates over a simulated connection.
|
|
let remote = cx.update(|cx| {
|
|
Worktree::remote(
|
|
1,
|
|
clock::ReplicaId::new(1),
|
|
proto::WorktreeMetadata {
|
|
id: 1,
|
|
root_name: "root".to_string(),
|
|
visible: true,
|
|
abs_path: path!("/root").to_string(),
|
|
root_repo_common_dir: None,
|
|
root_repo_is_linked_worktree: false,
|
|
},
|
|
AnyProtoClient::new(NoopProtoClient::new()),
|
|
PathStyle::local(),
|
|
cx,
|
|
)
|
|
});
|
|
|
|
// Relay every `UpdateWorktree` the host emits into the remote worktree,
|
|
// mirroring how the collab server forwards them. The callback only buffers
|
|
// the messages; we apply them on the foreground via `relay`.
|
|
let pending: Arc<Mutex<Vec<proto::UpdateWorktree>>> = Arc::new(Mutex::new(Vec::new()));
|
|
host.update(cx, |host, cx| {
|
|
let pending = pending.clone();
|
|
host.as_local_mut()
|
|
.unwrap()
|
|
.observe_updates(1, cx, move |update| {
|
|
pending.lock().push(update);
|
|
async { true }
|
|
});
|
|
});
|
|
let relay = {
|
|
let remote = remote.clone();
|
|
move |cx: &mut TestAppContext| {
|
|
let updates = std::mem::take(&mut *pending.lock());
|
|
remote.update(cx, |remote, _| {
|
|
let remote = remote.as_remote().unwrap();
|
|
for update in updates {
|
|
remote.update_from_remote(update);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// Record the (path, change) pairs from every `UpdatedEntries` event the
|
|
// remote worktree emits.
|
|
let changes: Arc<Mutex<Vec<(String, PathChange)>>> = Arc::new(Mutex::new(Vec::new()));
|
|
cx.update(|cx| {
|
|
let changes = changes.clone();
|
|
cx.subscribe(&remote, move |_, event, _cx| {
|
|
if let Event::UpdatedEntries(updated) = event {
|
|
changes.lock().extend(
|
|
updated
|
|
.iter()
|
|
.map(|(path, _, change)| (path.as_unix_str().to_string(), *change)),
|
|
);
|
|
}
|
|
})
|
|
.detach();
|
|
});
|
|
|
|
// Flush the initial sync (root + existing dirs) and ignore those paths.
|
|
cx.run_until_parked();
|
|
relay(cx);
|
|
cx.run_until_parked();
|
|
changes.lock().clear();
|
|
|
|
// Paste a skill folder into `.agents/skills` on the host.
|
|
fs.insert_tree(
|
|
path!("/root/.agents/skills/skill-1"),
|
|
json!({ "SKILL.md": "skill" }),
|
|
)
|
|
.await;
|
|
cx.run_until_parked();
|
|
relay(cx);
|
|
cx.run_until_parked();
|
|
|
|
{
|
|
let changes = changes.lock();
|
|
assert!(
|
|
changes
|
|
.iter()
|
|
.any(|(path, change)| path == ".agents/skills/skill-1/SKILL.md"
|
|
&& *change == PathChange::AddedOrUpdated),
|
|
"remote UpdatedEntries should carry the added skill path, got {:?}",
|
|
changes
|
|
);
|
|
}
|
|
changes.lock().clear();
|
|
|
|
// Remove the skill folder. The wire format only carries entry ids for
|
|
// removals, so the remote worktree must resolve their paths against the
|
|
// previous snapshot before it is replaced.
|
|
fs.remove_dir(
|
|
path!("/root/.agents/skills/skill-1").as_ref(),
|
|
RemoveOptions {
|
|
recursive: true,
|
|
ignore_if_not_exists: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
relay(cx);
|
|
cx.run_until_parked();
|
|
|
|
let changes = changes.lock();
|
|
assert!(
|
|
changes
|
|
.iter()
|
|
.any(|(path, change)| path == ".agents/skills/skill-1/SKILL.md"
|
|
&& *change == PathChange::Removed),
|
|
"remote UpdatedEntries should carry removed paths resolved from the \
|
|
previous snapshot, got {:?}",
|
|
changes
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_deferred_watch_repository_above_root(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(executor);
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
".git": {},
|
|
"subproject": {
|
|
"a.txt": "A"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
let worktree = Worktree::local(
|
|
path!("/root/subproject").as_ref(),
|
|
true,
|
|
fs.clone(),
|
|
Arc::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().scan_complete()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
worktree.update(cx, |worktree, cx| {
|
|
worktree.as_local_mut().unwrap().set_defer_watch(true, cx);
|
|
});
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().scan_complete()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
let repos = worktree.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().repositories()
|
|
});
|
|
pretty_assertions::assert_eq!(repos, [Path::new(path!("/root")).into()]);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_deferred_watch_symlinks_pointing_outside(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"dir1": {
|
|
"deps": {},
|
|
"src": {
|
|
"a.rs": "",
|
|
},
|
|
},
|
|
"dir2": {
|
|
"src": {
|
|
"c.rs": "",
|
|
}
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into())
|
|
.await
|
|
.unwrap();
|
|
|
|
let tree = Worktree::local(
|
|
Path::new("/root/dir1"),
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
tree.update(cx, |tree, cx| {
|
|
tree.as_local_mut().unwrap().set_defer_watch(true, cx);
|
|
});
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("deps"), false),
|
|
(rel_path("deps/dep-dir2"), true),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/a.rs"), false),
|
|
]
|
|
);
|
|
});
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("deps/dep-dir2").into()])
|
|
})
|
|
.recv()
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.is_external))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), false),
|
|
(rel_path("deps"), false),
|
|
(rel_path("deps/dep-dir2"), true),
|
|
(rel_path("deps/dep-dir2/src"), true),
|
|
(rel_path("src"), false),
|
|
(rel_path("src/a.rs"), false),
|
|
]
|
|
);
|
|
});
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("deps/dep-dir2/src").into()])
|
|
})
|
|
.recv()
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert!(
|
|
tree.entry_for_path(rel_path("deps/dep-dir2/src/c.rs"))
|
|
.is_some()
|
|
);
|
|
});
|
|
|
|
fs.insert_file(Path::new("/root/dir2/src/new.rs"), b"".to_vec())
|
|
.await;
|
|
|
|
wait_for_condition(cx, |cx| {
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.entry_for_path(rel_path("deps/dep-dir2/src/new.rs"))
|
|
.is_some()
|
|
})
|
|
})
|
|
.await;
|
|
}
|