## Objective Prevent one invalid file pattern from discarding valid patterns or causing settings loading to panic. The pre-existing exclusion-matcher bug was reported during review of #64492 (part of #64399). ## Solution Ignore invalid entries individually and log their errors in `file_scan_exclusions`, `file_scan_inclusions`, `file_types`, `hidden_files`, `private_files`, and `read_only_files`. Preserve valid inherited entries without changing `"..."`, replacement, or empty list behavior. Valid brace inclusion patterns remain usable, including when an alternative spans multiple directories. ## Testing Reproduced matcher loss and inclusion and file type panic paths before fixing them. Targeted tests pass on macOS for mixed, inherited, replacement, empty, and all-invalid lists, including inherited `.git` exclusions, private file protections, and inclusion rescans with brace patterns. Windows and Linux execution remains untested. ## Self-Review Checklist: - [x] I’ve reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Fixed invalid file patterns discarding valid exclusions, hidden file rules, or read-only rules, or causing inclusion settings to panic. Also fixed invalid private file patterns discarding valid protections and invalid file type patterns causing settings loading to panic. --------- Co-authored-by: Kirill Bulatov <kirill@zed.dev>
7420 lines
217 KiB
Rust
7420 lines
217 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, Entity, 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::{LocalSettingsKind, LocalSettingsPath, SettingsStore, SplicingVec, 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]
|
|
async fn test_entry_id_is_reused_when_rename_events_are_split(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"one.rs": "const ONE: usize = 1;",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = Worktree::local(
|
|
Path::new(path!("/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 old_entry_id = tree.read_with(cx, |tree, _| {
|
|
tree.entry_for_path(rel_path("one.rs")).unwrap().id
|
|
});
|
|
|
|
fs.pause_events();
|
|
fs.rename(
|
|
Path::new(path!("/root/one.rs")),
|
|
Path::new(path!("/root/three.rs")),
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(fs.buffered_event_count(), 2);
|
|
fs.flush_events(1);
|
|
cx.executor().run_until_parked();
|
|
fs.flush_events(1);
|
|
cx.executor().run_until_parked();
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(tree.entry_for_path(rel_path("one.rs")), None);
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("three.rs")).unwrap().id,
|
|
old_entry_id,
|
|
"a rename whose removal and creation events arrive in separate batches must reuse the entry id"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_rescan_requests_processed_before_root_creation_events(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
|
|
let tree = Worktree::local(
|
|
Path::new(path!("/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.pause_events();
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"a": {
|
|
"b": { "f1": "" },
|
|
"c": { "f2": "" },
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let mut refresh = tree.update(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("a/b/f1").into()])
|
|
});
|
|
refresh.recv().await;
|
|
let mut refresh = tree.update(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("a/c/f2").into()])
|
|
});
|
|
refresh.recv().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("a"),
|
|
rel_path("a/b"),
|
|
rel_path("a/b/f1"),
|
|
rel_path("a/c"),
|
|
rel_path("a/c/f2"),
|
|
]
|
|
);
|
|
});
|
|
|
|
fs.unpause_events_and_flush();
|
|
cx.executor().run_until_parked();
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
rel_path(""),
|
|
rel_path("a"),
|
|
rel_path("a/b"),
|
|
rel_path("a/b/f1"),
|
|
rel_path("a/c"),
|
|
rel_path("a/c/f2"),
|
|
]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[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,
|
|
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,
|
|
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 = 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_keeps_root_watcher_registered(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;
|
|
|
|
// Dropping and re-registering the root watch would open a window in
|
|
// which filesystem events are lost.
|
|
fs.emit_fs_event("/root", Some(PathEventKind::Rescan));
|
|
tree.flush_fs_events(cx).await;
|
|
|
|
let root_watch_calls = fs
|
|
.watch_calls()
|
|
.into_iter()
|
|
.filter(|path| path == Path::new("/root"))
|
|
.count();
|
|
assert_eq!(root_watch_calls, 1);
|
|
}
|
|
|
|
#[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,
|
|
RealFs::new(None, cx.executor()),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.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(SplicingVec::from(vec![]));
|
|
settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![
|
|
"**/.DS_Store".to_string(),
|
|
"node_modules/**/package.json".to_string(),
|
|
]));
|
|
});
|
|
});
|
|
});
|
|
|
|
let tree = Worktree::local(
|
|
dir.path(),
|
|
true,
|
|
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_inclusions_from_project_settings(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let worktree_id = WorktreeId::from_proto(0);
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(vec![
|
|
"...".to_string(),
|
|
"[".to_string(),
|
|
"ignored/**/excluded.project".to_string(),
|
|
]));
|
|
settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![
|
|
"...".to_string(),
|
|
"ignored/**/*.user".to_string(),
|
|
]));
|
|
});
|
|
store
|
|
.set_local_settings(
|
|
worktree_id,
|
|
LocalSettingsPath::InWorktree(Arc::from(RelPath::empty())),
|
|
LocalSettingsKind::Settings,
|
|
Some(r#"{ "file_scan_inclusions": ["...", "ignored/**/*.project"] }"#),
|
|
cx,
|
|
)
|
|
.expect("valid project settings");
|
|
});
|
|
});
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
".env.local": "",
|
|
".git": {},
|
|
".gitignore": ".env*\n/ignored/\n/one/\n/three/\nunmatched.txt\n",
|
|
"ignored": {
|
|
"nested": {
|
|
"excluded.project": "",
|
|
"included.project": "",
|
|
"included.user": "",
|
|
"unmatched.txt": ""
|
|
}
|
|
},
|
|
"one": { "two": { "file.rs": "" } },
|
|
"three": { "file.rs": "" },
|
|
"unmatched.txt": ""
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = build_worktree(fs, path!("/root"), cx).await;
|
|
tree.read_with(cx, |tree, _| {
|
|
for path in [
|
|
".env.local",
|
|
"ignored",
|
|
"ignored/nested",
|
|
"ignored/nested/included.project",
|
|
"ignored/nested/included.user",
|
|
] {
|
|
assert!(
|
|
tree.entry_for_path(rel_path(path))
|
|
.is_some_and(|entry| entry.is_ignored && entry.is_always_included),
|
|
"expected {path} to be ignored by Git and included by settings"
|
|
);
|
|
}
|
|
for path in ["ignored/nested/unmatched.txt", "unmatched.txt"] {
|
|
assert!(
|
|
tree.entry_for_path(rel_path(path))
|
|
.is_some_and(|entry| entry.is_ignored && !entry.is_always_included),
|
|
"expected {path} to remain ignored"
|
|
);
|
|
}
|
|
assert_eq!(tree.entry_for_path(rel_path(".git")), None);
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("ignored/nested/excluded.project")),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
tree.files(false, 0)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
rel_path(".env.local"),
|
|
rel_path(".gitignore"),
|
|
rel_path("ignored/nested/included.project"),
|
|
rel_path("ignored/nested/included.user"),
|
|
]
|
|
);
|
|
});
|
|
|
|
let ignored_directories = ["ignored", "ignored/nested", "one", "one/two", "three"];
|
|
for (settings, included_paths, always_included_directories) in [
|
|
(
|
|
r#"{ "file_scan_inclusions": ["...", "ignored/**/*.project", "{one/two,three}/file.rs", "invalid/["] }"#,
|
|
vec![
|
|
rel_path(".env.local"),
|
|
rel_path(".gitignore"),
|
|
rel_path("ignored/nested/included.project"),
|
|
rel_path("ignored/nested/included.user"),
|
|
rel_path("one/two/file.rs"),
|
|
rel_path("three/file.rs"),
|
|
],
|
|
&ignored_directories[..],
|
|
),
|
|
(
|
|
r#"{ "file_scan_inclusions": ["{one/two,three}/file.rs"] }"#,
|
|
vec![
|
|
rel_path(".gitignore"),
|
|
rel_path("one/two/file.rs"),
|
|
rel_path("three/file.rs"),
|
|
],
|
|
&["one", "one/two", "three"][..],
|
|
),
|
|
(
|
|
r#"{ "file_scan_inclusions": ["ignored/{nested,missing}/included.project"] }"#,
|
|
vec![
|
|
rel_path(".gitignore"),
|
|
rel_path("ignored/nested/included.project"),
|
|
],
|
|
&["ignored", "ignored/nested"][..],
|
|
),
|
|
(
|
|
r#"{ "file_scan_inclusions": ["ignored/**/*.project"] }"#,
|
|
vec![
|
|
rel_path(".gitignore"),
|
|
rel_path("ignored/nested/included.project"),
|
|
],
|
|
&["ignored", "ignored/nested"][..],
|
|
),
|
|
(
|
|
r#"{ "file_scan_inclusions": ["invalid/["] }"#,
|
|
vec![rel_path(".gitignore")],
|
|
&[][..],
|
|
),
|
|
(
|
|
r#"{ "file_scan_inclusions": [] }"#,
|
|
vec![rel_path(".gitignore")],
|
|
&[][..],
|
|
),
|
|
] {
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store
|
|
.set_local_settings(
|
|
worktree_id,
|
|
LocalSettingsPath::InWorktree(Arc::from(RelPath::empty())),
|
|
LocalSettingsKind::Settings,
|
|
Some(settings),
|
|
cx,
|
|
)
|
|
.expect("valid project settings");
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local().expect("local worktree").scan_complete()
|
|
})
|
|
.await;
|
|
tree.read_with(cx, |tree, _| {
|
|
for path in ignored_directories {
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path(path))
|
|
.is_some_and(|entry| entry.is_always_included),
|
|
always_included_directories.contains(&path),
|
|
"ancestor inclusion for {path} with {settings}"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
tree.files(false, 0)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
included_paths
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[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(SplicingVec::from(vec!["**/.DS_Store".to_string()]));
|
|
settings.project.worktree.file_scan_inclusions =
|
|
Some(SplicingVec::from(vec!["**/.DS_Store".to_string()]));
|
|
});
|
|
});
|
|
});
|
|
|
|
let tree = Worktree::local(
|
|
dir.path(),
|
|
true,
|
|
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(SplicingVec::from(vec![]));
|
|
settings.project.worktree.file_scan_inclusions =
|
|
Some(SplicingVec::from(vec!["node_modules/**".to_string()]));
|
|
});
|
|
});
|
|
});
|
|
let tree = Worktree::local(
|
|
dir.path(),
|
|
true,
|
|
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(SplicingVec::from(vec![]));
|
|
settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(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(SplicingVec::from(vec![
|
|
"**/foo/**".to_string(),
|
|
"**/.DS_Store".to_string(),
|
|
]));
|
|
});
|
|
});
|
|
});
|
|
|
|
let tree = Worktree::local(
|
|
dir.path(),
|
|
true,
|
|
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(SplicingVec::from(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,
|
|
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(SplicingVec::from(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_hidden_files_from_project_settings(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let worktree_id = WorktreeId::from_proto(0);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
".hidden_dir": {
|
|
"nested.rs": ""
|
|
},
|
|
".hidden_file": "",
|
|
"app.log": "",
|
|
"generated": {
|
|
"nested.rs": ""
|
|
},
|
|
"visible.rs": ""
|
|
}),
|
|
)
|
|
.await;
|
|
let tree = build_worktree(fs, path!("/root"), cx).await;
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.filter(|entry| entry.is_hidden)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
rel_path(".hidden_dir"),
|
|
rel_path(".hidden_dir/nested.rs"),
|
|
rel_path(".hidden_file"),
|
|
]
|
|
);
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.hidden_files = Some(SplicingVec::from(vec![
|
|
"**/*.log".to_string(),
|
|
SplicingVec::REST.to_string(),
|
|
]));
|
|
});
|
|
});
|
|
});
|
|
|
|
for (settings, hidden_paths) in [
|
|
(
|
|
None,
|
|
vec![
|
|
rel_path(".hidden_dir"),
|
|
rel_path(".hidden_dir/nested.rs"),
|
|
rel_path(".hidden_file"),
|
|
rel_path("app.log"),
|
|
],
|
|
),
|
|
(
|
|
Some(r#"{ "hidden_files": ["**/generated", "..."] }"#),
|
|
vec![
|
|
rel_path(".hidden_dir"),
|
|
rel_path(".hidden_dir/nested.rs"),
|
|
rel_path(".hidden_file"),
|
|
rel_path("app.log"),
|
|
rel_path("generated"),
|
|
rel_path("generated/nested.rs"),
|
|
],
|
|
),
|
|
(
|
|
Some(r#"{ "hidden_files": ["**/generated"] }"#),
|
|
vec![rel_path("generated"), rel_path("generated/nested.rs")],
|
|
),
|
|
(Some(r#"{ "hidden_files": [] }"#), vec![]),
|
|
] {
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store
|
|
.set_local_settings(
|
|
worktree_id,
|
|
LocalSettingsPath::InWorktree(Arc::from(RelPath::empty())),
|
|
LocalSettingsKind::Settings,
|
|
settings,
|
|
cx,
|
|
)
|
|
.expect("valid project settings");
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local().expect("local worktree").scan_complete()
|
|
})
|
|
.await;
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.filter(|entry| entry.is_hidden)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
hidden_paths
|
|
);
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| entry.path.as_ref())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
rel_path(""),
|
|
rel_path(".hidden_dir"),
|
|
rel_path(".hidden_dir/nested.rs"),
|
|
rel_path(".hidden_file"),
|
|
rel_path("app.log"),
|
|
rel_path("generated"),
|
|
rel_path("generated/nested.rs"),
|
|
rel_path("visible.rs"),
|
|
]
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[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(SplicingVec::from(vec![
|
|
"**/.git".to_string(),
|
|
"node_modules/".to_string(),
|
|
"build_output".to_string(),
|
|
]));
|
|
});
|
|
});
|
|
});
|
|
|
|
let tree = Worktree::local(
|
|
dir.path(),
|
|
true,
|
|
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,
|
|
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 = 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) {
|
|
if rng.random_bool(0.2) {
|
|
log::info!(
|
|
"simulating watcher overflow, losing {} events",
|
|
buffered_event_count
|
|
);
|
|
fs.as_fake().simulate_watcher_overflow(root_dir);
|
|
} else {
|
|
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 file_scan_depth = worktree.read_with(cx, |tree, _| {
|
|
tree.as_local().unwrap().settings().file_scan_depth
|
|
});
|
|
let expanded_paths = snapshot
|
|
.expanded_entries(file_scan_depth)
|
|
.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
|
|
}
|
|
}
|
|
|
|
#[gpui::test(iterations = 100)]
|
|
async fn test_random_git_updates_with_watcher_overflows(cx: &mut TestAppContext, mut rng: StdRng) {
|
|
// Property: every git state change is eventually signaled via
|
|
// `UpdatedGitRepositories`, no matter how the events reporting it are
|
|
// batched, delayed, or lost to watcher overflows.
|
|
init_test(cx);
|
|
let operations = env::var("OPERATIONS")
|
|
.map(|o| o.parse().unwrap())
|
|
.unwrap_or(40);
|
|
|
|
let root_dir = Path::new(path!("/test"));
|
|
let dot_git = root_dir.join(".git");
|
|
// Random fs mutations are confined to this subdirectory so that they
|
|
// cannot rename or delete `.git` itself.
|
|
let src_dir = root_dir.join("src");
|
|
let fs = FakeFs::new(cx.background_executor.clone()) as Arc<dyn Fs>;
|
|
fs.as_fake()
|
|
.insert_tree(
|
|
root_dir,
|
|
json!({
|
|
".git": {},
|
|
"src": {
|
|
"main.rs": "fn main() {}",
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let worktree = Worktree::local(
|
|
root_dir,
|
|
true,
|
|
fs.clone(),
|
|
Default::default(),
|
|
true,
|
|
WorktreeId::from_proto(0),
|
|
&mut cx.to_async(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
worktree
|
|
.update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete())
|
|
.await;
|
|
cx.executor().run_until_parked();
|
|
|
|
// Set when the fake repository's state is mutated, cleared when the
|
|
// worktree signals a git update. A signal observed after a mutation
|
|
// prompts downstream consumers (the GitStore) to re-read the repository,
|
|
// at which point they see that mutation's state, so clearing on any
|
|
// subsequent signal is sound.
|
|
let pending_git_update: Rc<Cell<bool>> = Rc::new(Cell::new(false));
|
|
worktree.update(cx, {
|
|
let pending_git_update = pending_git_update.clone();
|
|
|_, cx| {
|
|
cx.subscribe(&cx.entity(), move |_, _, event, _| {
|
|
if matches!(event, Event::UpdatedGitRepositories(_)) {
|
|
pending_git_update.set(false);
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
});
|
|
|
|
fs.as_fake().pause_events();
|
|
let mut commit_count = 0;
|
|
for _ in 0..operations {
|
|
match rng.random_range(0_u32..100) {
|
|
// Change the repository's git state, e.g. a commit moving HEAD.
|
|
0..25 => {
|
|
commit_count += 1;
|
|
log::info!("setting HEAD to commit {commit_count}");
|
|
fs.as_fake().set_head_for_repo(
|
|
&dot_git,
|
|
&[("src/main.rs", format!("fn main() {{}} // {commit_count}"))],
|
|
format!("sha-{commit_count}"),
|
|
);
|
|
pending_git_update.set(true);
|
|
}
|
|
// The watch queue overflows: all undelivered events are lost and
|
|
// only a rescan for the root is reported.
|
|
25..40 => {
|
|
log::info!(
|
|
"simulating watcher overflow, losing {} events",
|
|
fs.as_fake().buffered_event_count()
|
|
);
|
|
fs.as_fake().simulate_watcher_overflow(root_dir);
|
|
}
|
|
// Deliver a prefix of the queued events.
|
|
40..65 => {
|
|
let buffered_event_count = fs.as_fake().buffered_event_count();
|
|
let len = rng.random_range(0..=buffered_event_count);
|
|
log::info!("flushing {len} of {buffered_event_count} events");
|
|
fs.as_fake().flush_events(len);
|
|
}
|
|
// Unrelated churn in the working tree.
|
|
_ => {
|
|
randomly_mutate_fs(&fs, &src_dir, 1.0, &mut rng).await;
|
|
}
|
|
}
|
|
cx.executor().run_until_parked();
|
|
}
|
|
|
|
log::info!("quiescing");
|
|
fs.as_fake().unpause_events_and_flush();
|
|
cx.executor().run_until_parked();
|
|
|
|
worktree.read_with(cx, |tree, _| {
|
|
tree.as_local().unwrap().snapshot().check_invariants(true)
|
|
});
|
|
assert!(
|
|
!pending_git_update.get(),
|
|
"a git state change was never signaled via UpdatedGitRepositories"
|
|
);
|
|
}
|
|
|
|
// 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.clone(), cx);
|
|
|
|
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.into(), 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 project_path = home.join("example.com").join("project");
|
|
let fs = FakeFs::new(executor);
|
|
fs.insert_tree(
|
|
home,
|
|
json!({
|
|
".config": {
|
|
"git": {
|
|
"ignore": "foo\n/bar\nbaz\n*.com\n"
|
|
}
|
|
},
|
|
"example.com": {
|
|
"project": {
|
|
".git": {},
|
|
".gitignore": "!baz",
|
|
"foo": "",
|
|
"bar": "",
|
|
"sub": {
|
|
"bar": "",
|
|
},
|
|
"subrepo": {
|
|
".git": {},
|
|
"bar": ""
|
|
},
|
|
"baz": ""
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
let worktree = Worktree::local(
|
|
project_path.clone(),
|
|
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*.com\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(
|
|
&project_path.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_global_gitignore_without_repository(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
|
|
let home = paths::home_dir();
|
|
let project_path = home.join("builds").join("my-project.com");
|
|
let fs = FakeFs::new(executor);
|
|
fs.insert_tree(
|
|
home,
|
|
json!({
|
|
".config": {
|
|
"git": {
|
|
"ignore": "foo\nbuilds\n*.com\n"
|
|
}
|
|
},
|
|
"builds": {
|
|
"my-project.com": {
|
|
"foo": "",
|
|
"keep_me": "",
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let worktree = Worktree::local(
|
|
project_path.clone(),
|
|
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();
|
|
|
|
// Neither `builds` (an outside ancestor) nor `.com` (the root's own name) may ignore everything, but `foo` still should.
|
|
worktree.update(cx, |worktree, _cx| {
|
|
check_worktree_entries(
|
|
worktree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &["foo"],
|
|
tracked_paths: &["keep_me"],
|
|
..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_naming_a_worktree_ancestor(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(executor);
|
|
|
|
fs.insert_tree(
|
|
path!("/scratch/proj"),
|
|
json!({
|
|
".git": {
|
|
"info": { "exclude": "scratch" }
|
|
},
|
|
"src": {
|
|
"main.rs": "fn main() {}",
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let worktree = Worktree::local(
|
|
path!("/scratch/proj").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();
|
|
|
|
worktree.update(cx, |worktree, _cx| {
|
|
assert!(
|
|
!worktree.root_entry().unwrap().is_ignored,
|
|
"an exclude pattern matching an ancestor must not ignore the worktree"
|
|
);
|
|
check_worktree_entries(
|
|
worktree,
|
|
WorktreeExpectations {
|
|
tracked_paths: &["src/main.rs"],
|
|
..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()
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repo_exclude_applies_within_nested_repos(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(executor);
|
|
let project_dir = Path::new(path!("/project"));
|
|
|
|
// Mirrors the layout used by tools that keep working copies of the
|
|
// repository inside the repository itself: a bare clone in
|
|
// `.scratch/clones` and a linked worktree of that clone in
|
|
// `.scratch/worktrees`, both hidden via the outer repository's
|
|
// `.git/info/exclude` rather than a `.gitignore`.
|
|
fs.insert_tree(
|
|
project_dir,
|
|
json!({
|
|
".git": {
|
|
"info": {
|
|
"exclude": "/.scratch/worktrees/\n/.scratch/clones/\n"
|
|
}
|
|
},
|
|
"src": {
|
|
"main.rs": "fn main() {}",
|
|
},
|
|
".scratch": {
|
|
"clones": {
|
|
"abc": {
|
|
"project.git": {
|
|
"HEAD": "ref: refs/heads/main",
|
|
"worktrees": {
|
|
"project": {
|
|
"HEAD": "ref: refs/heads/feature",
|
|
"commondir": "../..",
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"worktrees": {
|
|
"abc": {
|
|
"project": {
|
|
".git": "gitdir: ../../../clones/abc/project.git/worktrees/project",
|
|
"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();
|
|
|
|
// After the initial scan, both excluded directories are ignored.
|
|
worktree.update(cx, |worktree, _cx| {
|
|
check_worktree_entries(
|
|
worktree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &[".scratch/clones", ".scratch/worktrees"],
|
|
tracked_paths: &["src/main.rs"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
|
|
// Load a file within the excluded nested repository, as happens when a
|
|
// search that includes ignored files runs or when the file is opened.
|
|
worktree
|
|
.update(cx, |worktree, _| {
|
|
worktree.as_local().unwrap().refresh_entries_for_paths(vec![
|
|
rel_path(".scratch/worktrees/abc/project/src/main.rs").into(),
|
|
])
|
|
})
|
|
.recv()
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
// The nested repository's own `.git` must not cause the outer
|
|
// repository's `info/exclude` rules to be dropped.
|
|
worktree.update(cx, |worktree, _cx| {
|
|
check_worktree_entries(
|
|
worktree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &[
|
|
".scratch/worktrees/abc",
|
|
".scratch/worktrees/abc/project",
|
|
".scratch/worktrees/abc/project/src",
|
|
".scratch/worktrees/abc/project/src/main.rs",
|
|
],
|
|
tracked_paths: &["src/main.rs"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
|
|
// A file written inside the loaded nested repository (e.g. by a tool
|
|
// working in the clone) must also be ignored.
|
|
fs.save(
|
|
path!("/project/.scratch/worktrees/abc/project/src/generated.rs").as_ref(),
|
|
&"fn generated() {}".into(),
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
worktree.update(cx, |worktree, _cx| {
|
|
check_worktree_entries(
|
|
worktree,
|
|
WorktreeExpectations {
|
|
ignored_paths: &[".scratch/worktrees/abc/project/src/generated.rs"],
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
|
|
// Nothing under the excluded directories is visible to a traversal that
|
|
// skips ignored entries, which is what project search uses.
|
|
worktree.update(cx, |worktree, _cx| {
|
|
let unignored_entries = worktree
|
|
.entries(false, 0)
|
|
.filter(|entry| {
|
|
entry.path.starts_with(rel_path(".scratch"))
|
|
&& entry.path.as_ref() != rel_path(".scratch")
|
|
})
|
|
.map(|entry| entry.path.clone())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
unignored_entries,
|
|
Vec::<Arc<RelPath>>::new(),
|
|
"entries under the excluded .scratch directories leaked into the unignored traversal",
|
|
);
|
|
});
|
|
}
|
|
|
|
#[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_watcher_overflow_rescan_reloads_git_state(cx: &mut TestAppContext) {
|
|
// When the OS watch queue overflows, pending events are dropped and the
|
|
// watcher reports only a `Rescan` event for the worktree root. The dropped
|
|
// events may have included changes inside `.git`, so the rescan must
|
|
// trigger a git state reload even though no `.git` event is ever seen.
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
".git": {},
|
|
"file.txt": "content",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = Worktree::local(
|
|
path!("/root").as_ref(),
|
|
true,
|
|
fs.clone(),
|
|
Default::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();
|
|
}
|
|
});
|
|
|
|
// A git state change occurs while events are queued but undelivered, and
|
|
// is then lost to a watcher overflow: the only event the worktree ever
|
|
// receives is the root rescan.
|
|
fs.pause_events();
|
|
fs.set_head_for_repo(
|
|
path!("/root/.git").as_ref(),
|
|
&[("file.txt", "content".into())],
|
|
"sha-after-overflow",
|
|
);
|
|
fs.simulate_watcher_overflow(path!("/root"));
|
|
fs.unpause_events_and_flush();
|
|
cx.run_until_parked();
|
|
|
|
assert!(
|
|
repo_update_count.get() > 0,
|
|
"a watcher overflow rescan should reload git state, since the dropped \
|
|
events may have included .git changes"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_git_update_in_same_batch_as_rescan_is_not_lost(cx: &mut TestAppContext) {
|
|
// When a `.git` event and a watcher rescan arrive in the same batch,
|
|
// `update_git_repositories` stamps the repository's `git_dir_scan_id`, but
|
|
// the rescan then re-inserts the repository entry. If the re-insertion
|
|
// resets `git_dir_scan_id`, the stamp is lost before the snapshot diff can
|
|
// observe it. The git update must still be signaled via
|
|
// `UpdatedGitRepositories`.
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
".git": {},
|
|
"file.txt": "content",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = Worktree::local(
|
|
path!("/root").as_ref(),
|
|
true,
|
|
fs.clone(),
|
|
Default::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();
|
|
}
|
|
});
|
|
|
|
// Deliver the git change and the rescan in a single batch, as happens when
|
|
// a rescan arrives while other events are still queued.
|
|
fs.pause_events();
|
|
fs.set_head_for_repo(
|
|
path!("/root/.git").as_ref(),
|
|
&[("file.txt", "content".into())],
|
|
"sha-with-rescan",
|
|
);
|
|
fs.emit_fs_event(path!("/root"), Some(PathEventKind::Rescan));
|
|
fs.unpause_events_and_flush();
|
|
cx.run_until_parked();
|
|
|
|
assert!(
|
|
repo_update_count.get() > 0,
|
|
"a git update processed in the same batch as a rescan should still be \
|
|
signaled via 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: Event for .git/index.lock only 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.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 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 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_dot_git_event_explained_by_filtered_sibling_does_not_emit_git_repo_update(
|
|
executor: BackgroundExecutor,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
// On Windows, creating or deleting a file directly inside .git (such as
|
|
// git's transient index.lock) updates the directory's last-write time, so
|
|
// ReadDirectoryChangesW reports a Changed event for the .git directory
|
|
// itself alongside the event for the file. Bare .git events schedule a git
|
|
// rescan (to cope with coalesced events on macOS), but when the same batch
|
|
// contains a filtered-out event that explains the directory change, acting
|
|
// on the bare event turns every ignored lock file into a rescan. Since
|
|
// Zed's own rescans take .git/index.lock via `git diff`, that feeds back
|
|
// into an infinite loop of git scans.
|
|
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);
|
|
|
|
// The exact batch Windows delivers when git creates .git/index.lock.
|
|
{
|
|
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,
|
|
"a bare .git event accompanied only by a filtered index.lock event \
|
|
should NOT emit UpdatedGitRepositories"
|
|
);
|
|
}
|
|
|
|
// Same batch with the events in the opposite order.
|
|
{
|
|
let mut events = cx.events(&worktree);
|
|
fs.pause_events();
|
|
fs.emit_fs_event(dot_git.join("index.lock"), Some(PathEventKind::Removed));
|
|
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,
|
|
"event order within the batch should not matter for suppressing \
|
|
the bare .git event"
|
|
);
|
|
}
|
|
|
|
// A meaningful change in the same batch must still trigger a rescan.
|
|
{
|
|
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.emit_fs_event(dot_git.join("HEAD"), 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,
|
|
"a meaningful .git change in the same batch as a filtered event \
|
|
should still emit UpdatedGitRepositories"
|
|
);
|
|
}
|
|
|
|
// A standalone bare .git event (macOS event coalescing) must still
|
|
// trigger a rescan.
|
|
{
|
|
let mut events = cx.events(&worktree);
|
|
fs.pause_events();
|
|
fs.emit_fs_event(dot_git, 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,
|
|
"a standalone bare .git event should still emit UpdatedGitRepositories"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
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.to_string(),
|
|
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_root_ancestor_rename_is_detected_without_fs_events(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/code"),
|
|
json!({
|
|
"project": {
|
|
"src": {
|
|
"main.rs": "fn main() {}",
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = Worktree::local(
|
|
Path::new(path!("/code/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;
|
|
|
|
// Renaming an ancestor of the root produces no event under the watched
|
|
// path, so only the periodic root check can notice it.
|
|
fs.rename(
|
|
Path::new(path!("/code")),
|
|
Path::new(path!("/src")),
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.background_executor.run_until_parked();
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(tree.abs_path().as_ref(), Path::new(path!("/code/project")));
|
|
});
|
|
|
|
cx.background_executor
|
|
.advance_clock(worktree::ROOT_PATH_CHECK_INTERVAL);
|
|
cx.background_executor.run_until_parked();
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(tree.abs_path().as_ref(), Path::new(path!("/src/project")));
|
|
assert_eq!(tree.root_name(), "project");
|
|
assert_eq!(
|
|
tree.entries(false, 0)
|
|
.map(|entry| entry.path.clone())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
rel_path("").into(),
|
|
rel_path("src").into(),
|
|
rel_path("src/main.rs").into(),
|
|
]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[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::Unix,
|
|
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,
|
|
is_unloaded: false,
|
|
}],
|
|
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::Unix,
|
|
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,
|
|
is_unloaded: false,
|
|
}],
|
|
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::Unix,
|
|
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;
|
|
}
|
|
|
|
#[test]
|
|
fn test_repo_exclude_does_not_match_outside_its_work_directory() {
|
|
use ignore::gitignore::GitignoreBuilder;
|
|
use worktree::{IgnoreKind, IgnoreStack};
|
|
|
|
let mut builder = GitignoreBuilder::new("/repo/inner");
|
|
builder.add_line(None, "build").unwrap();
|
|
builder.add_line(None, "repo").unwrap();
|
|
let exclude = Arc::new(builder.build().unwrap());
|
|
|
|
let stack = IgnoreStack::none().append(IgnoreKind::RepoExclude, exclude);
|
|
|
|
assert!(
|
|
stack.is_abs_path_ignored(Path::new("/repo/inner/build"), true),
|
|
"patterns must apply within the repository's work directory"
|
|
);
|
|
assert!(
|
|
!stack.is_abs_path_ignored(Path::new("/repo"), true),
|
|
"patterns must not apply outside the repository's work directory"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_depth_outside_repo(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
set_file_scan_depth(cx, Some(2));
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"junk": {
|
|
"a": {
|
|
"b": {
|
|
"c": {
|
|
"deep.txt": ""
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"repo": {
|
|
".git": {},
|
|
"src": {
|
|
"nested": {
|
|
"deep": {
|
|
"code.rs": ""
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"top.txt": ""
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = build_worktree(fs.clone(), path!("/root"), 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("junk"),
|
|
rel_path("junk/a"),
|
|
rel_path("repo"),
|
|
rel_path("repo/src"),
|
|
rel_path("repo/src/nested"),
|
|
rel_path("repo/src/nested/deep"),
|
|
rel_path("repo/src/nested/deep/code.rs"),
|
|
rel_path("top.txt"),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("junk/a")).unwrap().kind,
|
|
EntryKind::UnloadedDir
|
|
);
|
|
assert!(!tree.entry_for_path(rel_path("junk/a")).unwrap().is_ignored);
|
|
assert_eq!(tree.deferred_scan_dir_count(), 1);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_depth_settings_change(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
set_file_scan_depth(cx, Some(1));
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"junk": {
|
|
"a": {
|
|
"b": {
|
|
"deep.txt": ""
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(tree.entry_for_path(rel_path("junk/a/b/deep.txt")), None);
|
|
assert_eq!(tree.deferred_scan_dir_count(), 1);
|
|
});
|
|
|
|
set_file_scan_depth(cx, Some(0));
|
|
cx.run_until_parked();
|
|
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("junk/a/b/deep.txt"))
|
|
.map(|entry| entry.path.as_ref()),
|
|
Some(rel_path("junk/a/b/deep.txt"))
|
|
);
|
|
assert_eq!(tree.deferred_scan_dir_count(), 0);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_depth_inside_repo(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
set_file_scan_depth(cx, Some(1));
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
".git": {},
|
|
"a": {
|
|
"b": {
|
|
"c": {
|
|
"deep.txt": ""
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("a/b/c/deep.txt"))
|
|
.map(|entry| entry.path.as_ref()),
|
|
Some(rel_path("a/b/c/deep.txt"))
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_depth_inside_ancestor_repo(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
set_file_scan_depth(cx, Some(1));
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
".git": {},
|
|
"sub": {
|
|
"a": {
|
|
"b": {
|
|
"c": {
|
|
"deep.txt": ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = build_worktree(fs.clone(), path!("/root/sub"), cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("a/b/c/deep.txt"))
|
|
.map(|entry| entry.path.as_ref()),
|
|
Some(rel_path("a/b/c/deep.txt"))
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_depth_pierced_by_inclusions(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.file_scan_depth = Some(1);
|
|
settings.project.worktree.file_scan_inclusions =
|
|
Some(SplicingVec::from(vec!["junk/a/b/c/deep.txt".to_string()]));
|
|
});
|
|
});
|
|
});
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"junk": {
|
|
"a": {
|
|
"b": {
|
|
"c": {
|
|
"deep.txt": ""
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"other": {
|
|
"x": {}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("junk/a/b/c/deep.txt"))
|
|
.map(|entry| entry.path.as_ref()),
|
|
Some(rel_path("junk/a/b/c/deep.txt"))
|
|
);
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("other")).unwrap().kind,
|
|
EntryKind::UnloadedDir
|
|
);
|
|
assert_eq!(tree.entry_for_path(rel_path("other/x")), None);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_depth_expansion(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
set_file_scan_depth(cx, Some(1));
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"junk": {
|
|
"a": {
|
|
"file.txt": "",
|
|
"b": {
|
|
"c.txt": ""
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("junk")).unwrap().kind,
|
|
EntryKind::UnloadedDir
|
|
);
|
|
assert_eq!(tree.entry_for_path(rel_path("junk/a")), None);
|
|
});
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
tree.as_local()
|
|
.unwrap()
|
|
.refresh_entries_for_paths(vec![rel_path("junk/a").into()])
|
|
})
|
|
.recv()
|
|
.await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.kind))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), EntryKind::Dir),
|
|
(rel_path("junk"), EntryKind::Dir),
|
|
(rel_path("junk/a"), EntryKind::Dir),
|
|
(rel_path("junk/a/b"), EntryKind::UnloadedDir),
|
|
(rel_path("junk/a/file.txt"), EntryKind::File),
|
|
]
|
|
);
|
|
assert_eq!(tree.deferred_scan_dir_count(), 1);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_depth_fs_event_below_horizon(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
set_file_scan_depth(cx, Some(1));
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"junk": {
|
|
"a": {}
|
|
},
|
|
"top.txt": ""
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
|
|
|
fs.insert_file(Path::new(path!("/root/junk/a/new.txt")), b"".to_vec())
|
|
.await;
|
|
tree.flush_fs_events(cx).await;
|
|
cx.run_until_parked();
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entries(true, 0)
|
|
.map(|entry| (entry.path.as_ref(), entry.kind))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), EntryKind::Dir),
|
|
(rel_path("junk"), EntryKind::UnloadedDir),
|
|
(rel_path("top.txt"), EntryKind::File),
|
|
]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_depth_settings_tightening(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
set_file_scan_depth(cx, Some(0));
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"junk": {
|
|
"a": {
|
|
"b": {
|
|
"deep.txt": ""
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("junk/a/b/deep.txt"))
|
|
.map(|entry| entry.path.as_ref()),
|
|
Some(rel_path("junk/a/b/deep.txt"))
|
|
);
|
|
assert_eq!(tree.deferred_scan_dir_count(), 0);
|
|
});
|
|
|
|
set_file_scan_depth(cx, Some(1));
|
|
cx.run_until_parked();
|
|
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.kind))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(rel_path(""), EntryKind::Dir),
|
|
(rel_path("junk"), EntryKind::UnloadedDir),
|
|
]
|
|
);
|
|
assert_eq!(tree.deferred_scan_dir_count(), 1);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_depth_from_project_settings(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let worktree_id = WorktreeId::from_proto(0);
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store
|
|
.set_local_settings(
|
|
worktree_id,
|
|
LocalSettingsPath::InWorktree(Arc::from(RelPath::empty())),
|
|
LocalSettingsKind::Settings,
|
|
Some(r#"{ "file_scan_depth": 1 }"#),
|
|
cx,
|
|
)
|
|
.unwrap();
|
|
});
|
|
});
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"junk": {
|
|
"a": {
|
|
"b": {
|
|
"deep.txt": ""
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("junk")).unwrap().kind,
|
|
EntryKind::UnloadedDir
|
|
);
|
|
assert_eq!(tree.entry_for_path(rel_path("junk/a")), None);
|
|
assert_eq!(tree.deferred_scan_dir_count(), 1);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_file_scan_depth_git_init_above_deferred_dirs(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
set_file_scan_depth(cx, Some(2));
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"project": {
|
|
"src": {
|
|
"nested": {
|
|
"deep.txt": ""
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("project/src")).unwrap().kind,
|
|
EntryKind::UnloadedDir
|
|
);
|
|
assert_eq!(tree.deferred_scan_dir_count(), 1);
|
|
});
|
|
|
|
fs.create_dir(Path::new(path!("/root/project/.git")))
|
|
.await
|
|
.unwrap();
|
|
tree.flush_fs_events(cx).await;
|
|
cx.run_until_parked();
|
|
|
|
tree.read_with(cx, |tree, _| {
|
|
assert_eq!(
|
|
tree.entry_for_path(rel_path("project/src/nested/deep.txt"))
|
|
.map(|entry| entry.path.as_ref()),
|
|
Some(rel_path("project/src/nested/deep.txt"))
|
|
);
|
|
assert_eq!(tree.deferred_scan_dir_count(), 0);
|
|
});
|
|
}
|
|
|
|
async fn build_worktree(fs: Arc<FakeFs>, root: &str, cx: &mut TestAppContext) -> Entity<Worktree> {
|
|
let tree = Worktree::local(
|
|
Path::new(root),
|
|
true,
|
|
fs,
|
|
Arc::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
|
|
}
|
|
|
|
fn set_file_scan_depth(cx: &mut TestAppContext, depth: Option<u32>) {
|
|
cx.update(|cx| {
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.file_scan_depth = depth;
|
|
});
|
|
});
|
|
});
|
|
}
|