crates/project/src/project.rs 🔗
@@ -7,6 +7,8 @@ pub mod worktree;
#[cfg(test)]
mod project_tests;
+#[cfg(test)]
+mod worktree_tests;
use anyhow::{anyhow, Context, Result};
use client::{proto, Client, TypedEnvelope, UserStore};
Max Brunsfeld created
crates/project/src/project.rs | 2
crates/project/src/worktree.rs | 1682 +----------------------------
crates/project/src/worktree_tests.rs | 1532 +++++++++++++++++++++++++++
3 files changed, 1,614 insertions(+), 1,602 deletions(-)
@@ -7,6 +7,8 @@ pub mod worktree;
#[cfg(test)]
mod project_tests;
+#[cfg(test)]
+mod worktree_tests;
use anyhow::{anyhow, Context, Result};
use client::{proto, Client, TypedEnvelope, UserStore};
@@ -160,7 +160,7 @@ impl From<&RepositoryEntry> for proto::RepositoryEntry {
/// This path corresponds to the 'content path' (the folder that contains the .git)
#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
-pub struct RepositoryWorkDirectory(Arc<Path>);
+pub struct RepositoryWorkDirectory(pub(crate) Arc<Path>);
impl Default for RepositoryWorkDirectory {
fn default() -> Self {
@@ -212,7 +212,7 @@ pub struct LocalSnapshot {
git_repositories: TreeMap<ProjectEntryId, LocalRepositoryEntry>,
}
-pub struct BackgroundScannerState {
+struct BackgroundScannerState {
snapshot: LocalSnapshot,
/// The ids of all of the entries that were removed from the snapshot
/// as part of the current update. These entry ids may be re-used
@@ -1755,7 +1755,7 @@ impl Snapshot {
}
}
- fn descendent_entries<'a>(
+ pub fn descendent_entries<'a>(
&'a self,
include_dirs: bool,
include_ignored: bool,
@@ -2100,6 +2100,82 @@ impl LocalSnapshot {
}
}
+impl LocalSnapshot {
+ #[cfg(test)]
+ pub fn check_invariants(&self) {
+ assert_eq!(
+ self.entries_by_path
+ .cursor::<()>()
+ .map(|e| (&e.path, e.id))
+ .collect::<Vec<_>>(),
+ self.entries_by_id
+ .cursor::<()>()
+ .map(|e| (&e.path, e.id))
+ .collect::<collections::BTreeSet<_>>()
+ .into_iter()
+ .collect::<Vec<_>>(),
+ "entries_by_path and entries_by_id are inconsistent"
+ );
+
+ let mut files = self.files(true, 0);
+ let mut visible_files = self.files(false, 0);
+ for entry in self.entries_by_path.cursor::<()>() {
+ if entry.is_file() {
+ assert_eq!(files.next().unwrap().inode, entry.inode);
+ if !entry.is_ignored {
+ assert_eq!(visible_files.next().unwrap().inode, entry.inode);
+ }
+ }
+ }
+
+ assert!(files.next().is_none());
+ assert!(visible_files.next().is_none());
+
+ let mut bfs_paths = Vec::new();
+ let mut stack = vec![Path::new("")];
+ while let Some(path) = stack.pop() {
+ bfs_paths.push(path);
+ let ix = stack.len();
+ for child_entry in self.child_entries(path) {
+ stack.insert(ix, &child_entry.path);
+ }
+ }
+
+ let dfs_paths_via_iter = self
+ .entries_by_path
+ .cursor::<()>()
+ .map(|e| e.path.as_ref())
+ .collect::<Vec<_>>();
+ assert_eq!(bfs_paths, dfs_paths_via_iter);
+
+ let dfs_paths_via_traversal = self
+ .entries(true)
+ .map(|e| e.path.as_ref())
+ .collect::<Vec<_>>();
+ assert_eq!(dfs_paths_via_traversal, dfs_paths_via_iter);
+
+ for ignore_parent_abs_path in self.ignores_by_parent_abs_path.keys() {
+ let ignore_parent_path = ignore_parent_abs_path.strip_prefix(&self.abs_path).unwrap();
+ assert!(self.entry_for_path(&ignore_parent_path).is_some());
+ assert!(self
+ .entry_for_path(ignore_parent_path.join(&*GITIGNORE))
+ .is_some());
+ }
+ }
+
+ #[cfg(test)]
+ pub fn entries_without_ids(&self, include_ignored: bool) -> Vec<(&Path, u64, bool)> {
+ let mut paths = Vec::new();
+ for entry in self.entries_by_path.cursor::<()>() {
+ if include_ignored || !entry.is_ignored {
+ paths.push((entry.path.as_ref(), entry.inode, entry.is_ignored));
+ }
+ }
+ paths.sort_by(|a, b| a.0.cmp(b.0));
+ paths
+ }
+}
+
impl BackgroundScannerState {
fn reuse_entry_id(&mut self, entry: &mut Entry) {
if let Some(removed_entry_id) = self.removed_entry_ids.remove(&entry.inode) {
@@ -3877,7 +3953,7 @@ impl<'a> Iterator for ChildEntriesIter<'a> {
}
}
-struct DescendentEntriesIter<'a> {
+pub struct DescendentEntriesIter<'a> {
parent_path: &'a Path,
traversal: Traversal<'a>,
}
@@ -3942,1601 +4018,3 @@ impl<'a> TryFrom<(&'a CharBag, proto::Entry)> for Entry {
}
}
}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use fs::{FakeFs, RealFs};
- use gpui::{executor::Deterministic, TestAppContext};
- use pretty_assertions::assert_eq;
- use rand::prelude::*;
- use serde_json::json;
- use std::{env, fmt::Write};
- use util::{http::FakeHttpClient, test::temp_tree};
-
- #[gpui::test]
- async fn test_traversal(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.background());
- fs.insert_tree(
- "/root",
- json!({
- ".gitignore": "a/b\n",
- "a": {
- "b": "",
- "c": "",
- }
- }),
- )
- .await;
-
- let http_client = FakeHttpClient::with_404_response();
- let client = cx.read(|cx| Client::new(http_client, cx));
-
- let tree = Worktree::local(
- client,
- Path::new("/root"),
- true,
- fs,
- Default::default(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
- cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
- .await;
-
- tree.read_with(cx, |tree, _| {
- assert_eq!(
- tree.entries(false)
- .map(|entry| entry.path.as_ref())
- .collect::<Vec<_>>(),
- vec![
- Path::new(""),
- Path::new(".gitignore"),
- Path::new("a"),
- Path::new("a/c"),
- ]
- );
- assert_eq!(
- tree.entries(true)
- .map(|entry| entry.path.as_ref())
- .collect::<Vec<_>>(),
- vec![
- Path::new(""),
- Path::new(".gitignore"),
- Path::new("a"),
- Path::new("a/b"),
- Path::new("a/c"),
- ]
- );
- })
- }
-
- #[gpui::test]
- async fn test_descendent_entries(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.background());
- fs.insert_tree(
- "/root",
- json!({
- "a": "",
- "b": {
- "c": {
- "d": ""
- },
- "e": {}
- },
- "f": "",
- "g": {
- "h": {}
- },
- "i": {
- "j": {
- "k": ""
- },
- "l": {
-
- }
- },
- ".gitignore": "i/j\n",
- }),
- )
- .await;
-
- let http_client = FakeHttpClient::with_404_response();
- let client = cx.read(|cx| Client::new(http_client, cx));
-
- let tree = Worktree::local(
- client,
- Path::new("/root"),
- true,
- fs,
- Default::default(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
- cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
- .await;
-
- tree.read_with(cx, |tree, _| {
- assert_eq!(
- tree.descendent_entries(false, false, Path::new("b"))
- .map(|entry| entry.path.as_ref())
- .collect::<Vec<_>>(),
- vec![Path::new("b/c/d"),]
- );
- assert_eq!(
- tree.descendent_entries(true, false, Path::new("b"))
- .map(|entry| entry.path.as_ref())
- .collect::<Vec<_>>(),
- vec![
- Path::new("b"),
- Path::new("b/c"),
- Path::new("b/c/d"),
- Path::new("b/e"),
- ]
- );
-
- assert_eq!(
- tree.descendent_entries(false, false, Path::new("g"))
- .map(|entry| entry.path.as_ref())
- .collect::<Vec<_>>(),
- Vec::<PathBuf>::new()
- );
- assert_eq!(
- tree.descendent_entries(true, false, Path::new("g"))
- .map(|entry| entry.path.as_ref())
- .collect::<Vec<_>>(),
- vec![Path::new("g"), Path::new("g/h"),]
- );
-
- assert_eq!(
- tree.descendent_entries(false, false, Path::new("i"))
- .map(|entry| entry.path.as_ref())
- .collect::<Vec<_>>(),
- Vec::<PathBuf>::new()
- );
- assert_eq!(
- tree.descendent_entries(false, true, Path::new("i"))
- .map(|entry| entry.path.as_ref())
- .collect::<Vec<_>>(),
- vec![Path::new("i/j/k")]
- );
- assert_eq!(
- tree.descendent_entries(true, false, Path::new("i"))
- .map(|entry| entry.path.as_ref())
- .collect::<Vec<_>>(),
- vec![Path::new("i"), Path::new("i/l"),]
- );
- })
- }
-
- #[gpui::test(iterations = 10)]
- async fn test_circular_symlinks(executor: Arc<Deterministic>, cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.background());
- fs.insert_tree(
- "/root",
- json!({
- "lib": {
- "a": {
- "a.txt": ""
- },
- "b": {
- "b.txt": ""
- }
- }
- }),
- )
- .await;
- fs.insert_symlink("/root/lib/a/lib", "..".into()).await;
- fs.insert_symlink("/root/lib/b/lib", "..".into()).await;
-
- let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
- let tree = Worktree::local(
- client,
- Path::new("/root"),
- true,
- fs.clone(),
- Default::default(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
-
- cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
- .await;
-
- tree.read_with(cx, |tree, _| {
- assert_eq!(
- tree.entries(false)
- .map(|entry| entry.path.as_ref())
- .collect::<Vec<_>>(),
- vec![
- Path::new(""),
- Path::new("lib"),
- Path::new("lib/a"),
- Path::new("lib/a/a.txt"),
- Path::new("lib/a/lib"),
- Path::new("lib/b"),
- Path::new("lib/b/b.txt"),
- Path::new("lib/b/lib"),
- ]
- );
- });
-
- fs.rename(
- Path::new("/root/lib/a/lib"),
- Path::new("/root/lib/a/lib-2"),
- Default::default(),
- )
- .await
- .unwrap();
- executor.run_until_parked();
- tree.read_with(cx, |tree, _| {
- assert_eq!(
- tree.entries(false)
- .map(|entry| entry.path.as_ref())
- .collect::<Vec<_>>(),
- vec![
- Path::new(""),
- Path::new("lib"),
- Path::new("lib/a"),
- Path::new("lib/a/a.txt"),
- Path::new("lib/a/lib-2"),
- Path::new("lib/b"),
- Path::new("lib/b/b.txt"),
- Path::new("lib/b/lib"),
- ]
- );
- });
- }
-
- #[gpui::test]
- async fn test_rescan_with_gitignore(cx: &mut TestAppContext) {
- // .gitignores are handled explicitly by Zed and do not use the git
- // machinery that the git_tests module checks
- let parent_dir = temp_tree(json!({
- ".gitignore": "ancestor-ignored-file1\nancestor-ignored-file2\n",
- "tree": {
- ".git": {},
- ".gitignore": "ignored-dir\n",
- "tracked-dir": {
- "tracked-file1": "",
- "ancestor-ignored-file1": "",
- },
- "ignored-dir": {
- "ignored-file1": ""
- }
- }
- }));
- let dir = parent_dir.path().join("tree");
-
- let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
-
- let tree = Worktree::local(
- client,
- dir.as_path(),
- true,
- Arc::new(RealFs),
- Default::default(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
- cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
- .await;
- tree.flush_fs_events(cx).await;
- cx.read(|cx| {
- let tree = tree.read(cx);
- assert!(
- !tree
- .entry_for_path("tracked-dir/tracked-file1")
- .unwrap()
- .is_ignored
- );
- assert!(
- tree.entry_for_path("tracked-dir/ancestor-ignored-file1")
- .unwrap()
- .is_ignored
- );
- assert!(
- tree.entry_for_path("ignored-dir/ignored-file1")
- .unwrap()
- .is_ignored
- );
- });
-
- std::fs::write(dir.join("tracked-dir/tracked-file2"), "").unwrap();
- std::fs::write(dir.join("tracked-dir/ancestor-ignored-file2"), "").unwrap();
- std::fs::write(dir.join("ignored-dir/ignored-file2"), "").unwrap();
- tree.flush_fs_events(cx).await;
- cx.read(|cx| {
- let tree = tree.read(cx);
- assert!(
- !tree
- .entry_for_path("tracked-dir/tracked-file2")
- .unwrap()
- .is_ignored
- );
- assert!(
- tree.entry_for_path("tracked-dir/ancestor-ignored-file2")
- .unwrap()
- .is_ignored
- );
- assert!(
- tree.entry_for_path("ignored-dir/ignored-file2")
- .unwrap()
- .is_ignored
- );
- assert!(tree.entry_for_path(".git").unwrap().is_ignored);
- });
- }
-
- #[gpui::test]
- async fn test_write_file(cx: &mut TestAppContext) {
- let dir = temp_tree(json!({
- ".git": {},
- ".gitignore": "ignored-dir\n",
- "tracked-dir": {},
- "ignored-dir": {}
- }));
-
- let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
-
- let tree = Worktree::local(
- client,
- dir.path(),
- true,
- Arc::new(RealFs),
- Default::default(),
- &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.update(cx, |tree, cx| {
- tree.as_local().unwrap().write_file(
- Path::new("tracked-dir/file.txt"),
- "hello".into(),
- Default::default(),
- cx,
- )
- })
- .await
- .unwrap();
- tree.update(cx, |tree, cx| {
- tree.as_local().unwrap().write_file(
- Path::new("ignored-dir/file.txt"),
- "world".into(),
- Default::default(),
- cx,
- )
- })
- .await
- .unwrap();
-
- tree.read_with(cx, |tree, _| {
- let tracked = tree.entry_for_path("tracked-dir/file.txt").unwrap();
- let ignored = tree.entry_for_path("ignored-dir/file.txt").unwrap();
- assert!(!tracked.is_ignored);
- assert!(ignored.is_ignored);
- });
- }
-
- #[gpui::test(iterations = 30)]
- async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) {
- let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
-
- let fs = FakeFs::new(cx.background());
- fs.insert_tree(
- "/root",
- json!({
- "b": {},
- "c": {},
- "d": {},
- }),
- )
- .await;
-
- let tree = Worktree::local(
- client,
- "/root".as_ref(),
- true,
- fs,
- Default::default(),
- &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()));
- let _ = tree.observe_updates(0, cx, {
- let snapshot = snapshot.clone();
- move |update| {
- snapshot.lock().apply_remote_update(update).unwrap();
- async { true }
- }
- });
- snapshot
- });
-
- let entry = tree
- .update(cx, |tree, cx| {
- tree.as_local_mut()
- .unwrap()
- .create_entry("a/e".as_ref(), true, cx)
- })
- .await
- .unwrap();
- assert!(entry.is_dir());
-
- cx.foreground().run_until_parked();
- tree.read_with(cx, |tree, _| {
- assert_eq!(tree.entry_for_path("a/e").unwrap().kind, EntryKind::Dir);
- });
-
- let snapshot2 = tree.update(cx, |tree, _| tree.as_local().unwrap().snapshot());
- assert_eq!(
- snapshot1.lock().entries(true).collect::<Vec<_>>(),
- snapshot2.entries(true).collect::<Vec<_>>()
- );
- }
-
- #[gpui::test(iterations = 100)]
- async fn test_random_worktree_operations_during_initial_scan(
- cx: &mut TestAppContext,
- mut rng: StdRng,
- ) {
- 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("/test");
- let fs = FakeFs::new(cx.background()) 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 client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
- let worktree = Worktree::local(
- client.clone(),
- root_dir,
- true,
- fs.clone(),
- Default::default(),
- &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);
-
- let _ = 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()
- });
-
- if rng.gen_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.foreground().run_until_parked();
-
- let final_snapshot = worktree.read_with(cx, |tree, _| {
- let tree = tree.as_local().unwrap();
- tree.snapshot.check_invariants();
- tree.snapshot()
- });
-
- 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())
- .unwrap();
- }
- }
-
- assert_eq!(
- updated_snapshot.entries(true).collect::<Vec<_>>(),
- final_snapshot.entries(true).collect::<Vec<_>>(),
- "wrong updates after snapshot {i}: {snapshot:#?} {updates:#?}",
- );
- }
- }
-
- #[gpui::test(iterations = 100)]
- async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) {
- 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("/test");
- let fs = FakeFs::new(cx.background()) 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 client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
- let worktree = Worktree::local(
- client.clone(),
- root_dir,
- true,
- fs.clone(),
- Default::default(),
- &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);
-
- let _ = 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.gen_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.gen_bool(0.3) {
- let len = rng.gen_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.foreground().run_until_parked();
- if rng.gen_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.foreground().run_until_parked();
- let snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot());
- snapshot.check_invariants();
-
- {
- let new_worktree = Worktree::local(
- client.clone(),
- root_dir,
- true,
- fs.clone(),
- Default::default(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
- new_worktree
- .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete())
- .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)
- );
- }
-
- 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()).unwrap();
- }
- }
-
- assert_eq!(
- prev_snapshot.entries(true).collect::<Vec<_>>(),
- snapshot.entries(true).collect::<Vec<_>>(),
- "wrong updates after snapshot {i}: {updates:#?}",
- );
- }
- }
-
- // 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 ModelContext<Worktree>) {
- let mut entries = tree.entries(true).cloned().collect::<Vec<_>>();
- cx.subscribe(&cx.handle(), 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::Loaded => entries.insert(ix, entry.unwrap()),
- 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 => {
- 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).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 ModelContext<Worktree>,
- ) -> Task<Result<()>> {
- log::info!("mutating worktree");
- let worktree = worktree.as_local_mut().unwrap();
- let snapshot = worktree.snapshot();
- let entry = snapshot.entries(false).choose(rng).unwrap();
-
- match rng.gen_range(0_u32..100) {
- 0..=33 if entry.path.as_ref() != Path::new("") => {
- log::info!("deleting entry {:?} ({})", entry.path, entry.id.0);
- worktree.delete_entry(entry.id, cx).unwrap()
- }
- ..=66 if entry.path.as_ref() != Path::new("") => {
- let other_entry = snapshot.entries(false).choose(rng).unwrap();
- let new_parent_path = if other_entry.is_dir() {
- other_entry.path.clone()
- } else {
- other_entry.path.parent().unwrap().into()
- };
- let mut new_path = new_parent_path.join(gen_name(rng));
- if new_path.starts_with(&entry.path) {
- new_path = gen_name(rng).into();
- }
-
- log::info!(
- "renaming entry {:?} ({}) to {:?}",
- entry.path,
- entry.id.0,
- new_path
- );
- let task = worktree.rename_entry(entry.id, new_path, cx).unwrap();
- cx.foreground().spawn(async move {
- task.await?;
- Ok(())
- })
- }
- _ => {
- let task = if entry.is_dir() {
- let child_path = entry.path.join(gen_name(rng));
- let is_dir = rng.gen_bool(0.3);
- log::info!(
- "creating {} at {:?}",
- if is_dir { "dir" } else { "file" },
- child_path,
- );
- worktree.create_entry(child_path, is_dir, cx)
- } else {
- log::info!("overwriting file {:?} ({})", entry.path, entry.id.0);
- worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx)
- };
- cx.foreground().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.gen_bool(insertion_probability) {
- let path = dirs.choose(rng).unwrap();
- let new_path = path.join(gen_name(rng));
-
- if rng.gen() {
- 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.gen_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.gen_range(0..=subfiles.len());
- subfiles.choose_multiple(rng, len)
- };
- let dirs_to_ignore = {
- let len = rng.gen_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.gen();
- 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.gen_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(gen_name(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,
- },
- )
- .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 gen_name(rng: &mut impl Rng) -> String {
- (0..6)
- .map(|_| rng.sample(rand::distributions::Alphanumeric))
- .map(char::from)
- .collect()
- }
-
- impl LocalSnapshot {
- fn check_invariants(&self) {
- assert_eq!(
- self.entries_by_path
- .cursor::<()>()
- .map(|e| (&e.path, e.id))
- .collect::<Vec<_>>(),
- self.entries_by_id
- .cursor::<()>()
- .map(|e| (&e.path, e.id))
- .collect::<collections::BTreeSet<_>>()
- .into_iter()
- .collect::<Vec<_>>(),
- "entries_by_path and entries_by_id are inconsistent"
- );
-
- let mut files = self.files(true, 0);
- let mut visible_files = self.files(false, 0);
- for entry in self.entries_by_path.cursor::<()>() {
- if entry.is_file() {
- assert_eq!(files.next().unwrap().inode, entry.inode);
- if !entry.is_ignored {
- assert_eq!(visible_files.next().unwrap().inode, entry.inode);
- }
- }
- }
-
- assert!(files.next().is_none());
- assert!(visible_files.next().is_none());
-
- let mut bfs_paths = Vec::new();
- let mut stack = vec![Path::new("")];
- while let Some(path) = stack.pop() {
- bfs_paths.push(path);
- let ix = stack.len();
- for child_entry in self.child_entries(path) {
- stack.insert(ix, &child_entry.path);
- }
- }
-
- let dfs_paths_via_iter = self
- .entries_by_path
- .cursor::<()>()
- .map(|e| e.path.as_ref())
- .collect::<Vec<_>>();
- assert_eq!(bfs_paths, dfs_paths_via_iter);
-
- let dfs_paths_via_traversal = self
- .entries(true)
- .map(|e| e.path.as_ref())
- .collect::<Vec<_>>();
- assert_eq!(dfs_paths_via_traversal, dfs_paths_via_iter);
-
- for ignore_parent_abs_path in self.ignores_by_parent_abs_path.keys() {
- let ignore_parent_path =
- ignore_parent_abs_path.strip_prefix(&self.abs_path).unwrap();
- assert!(self.entry_for_path(&ignore_parent_path).is_some());
- assert!(self
- .entry_for_path(ignore_parent_path.join(&*GITIGNORE))
- .is_some());
- }
- }
-
- fn entries_without_ids(&self, include_ignored: bool) -> Vec<(&Path, u64, bool)> {
- let mut paths = Vec::new();
- for entry in self.entries_by_path.cursor::<()>() {
- if include_ignored || !entry.is_ignored {
- paths.push((entry.path.as_ref(), entry.inode, entry.is_ignored));
- }
- }
- paths.sort_by(|a, b| a.0.cmp(b.0));
- paths
- }
- }
-
- mod git_tests {
- use super::*;
- use pretty_assertions::assert_eq;
-
- #[gpui::test]
- async fn test_rename_work_directory(cx: &mut TestAppContext) {
- let root = temp_tree(json!({
- "projects": {
- "project1": {
- "a": "",
- "b": "",
- }
- },
-
- }));
- let root_path = root.path();
-
- let http_client = FakeHttpClient::with_404_response();
- let client = cx.read(|cx| Client::new(http_client, cx));
- let tree = Worktree::local(
- client,
- root_path,
- true,
- Arc::new(RealFs),
- Default::default(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
-
- let repo = git_init(&root_path.join("projects/project1"));
- git_add("a", &repo);
- git_commit("init", &repo);
- std::fs::write(root_path.join("projects/project1/a"), "aa").ok();
-
- cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
- .await;
-
- tree.flush_fs_events(cx).await;
-
- cx.read(|cx| {
- let tree = tree.read(cx);
- let (work_dir, _) = tree.repositories().next().unwrap();
- assert_eq!(work_dir.as_ref(), Path::new("projects/project1"));
- assert_eq!(
- tree.status_for_file(Path::new("projects/project1/a")),
- Some(GitFileStatus::Modified)
- );
- assert_eq!(
- tree.status_for_file(Path::new("projects/project1/b")),
- Some(GitFileStatus::Added)
- );
- });
-
- std::fs::rename(
- root_path.join("projects/project1"),
- root_path.join("projects/project2"),
- )
- .ok();
- tree.flush_fs_events(cx).await;
-
- cx.read(|cx| {
- let tree = tree.read(cx);
- let (work_dir, _) = tree.repositories().next().unwrap();
- assert_eq!(work_dir.as_ref(), Path::new("projects/project2"));
- assert_eq!(
- tree.status_for_file(Path::new("projects/project2/a")),
- Some(GitFileStatus::Modified)
- );
- assert_eq!(
- tree.status_for_file(Path::new("projects/project2/b")),
- Some(GitFileStatus::Added)
- );
- });
- }
-
- #[gpui::test]
- async fn test_git_repository_for_path(cx: &mut TestAppContext) {
- let root = temp_tree(json!({
- "c.txt": "",
- "dir1": {
- ".git": {},
- "deps": {
- "dep1": {
- ".git": {},
- "src": {
- "a.txt": ""
- }
- }
- },
- "src": {
- "b.txt": ""
- }
- },
- }));
-
- let http_client = FakeHttpClient::with_404_response();
- let client = cx.read(|cx| Client::new(http_client, cx));
- let tree = Worktree::local(
- client,
- root.path(),
- true,
- Arc::new(RealFs),
- Default::default(),
- &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, _cx| {
- let tree = tree.as_local().unwrap();
-
- assert!(tree.repository_for_path("c.txt".as_ref()).is_none());
-
- let entry = tree.repository_for_path("dir1/src/b.txt".as_ref()).unwrap();
- assert_eq!(
- entry
- .work_directory(tree)
- .map(|directory| directory.as_ref().to_owned()),
- Some(Path::new("dir1").to_owned())
- );
-
- let entry = tree
- .repository_for_path("dir1/deps/dep1/src/a.txt".as_ref())
- .unwrap();
- assert_eq!(
- entry
- .work_directory(tree)
- .map(|directory| directory.as_ref().to_owned()),
- Some(Path::new("dir1/deps/dep1").to_owned())
- );
-
- let entries = tree.files(false, 0);
-
- let paths_with_repos = tree
- .entries_with_repositories(entries)
- .map(|(entry, repo)| {
- (
- entry.path.as_ref(),
- repo.and_then(|repo| {
- repo.work_directory(&tree)
- .map(|work_directory| work_directory.0.to_path_buf())
- }),
- )
- })
- .collect::<Vec<_>>();
-
- assert_eq!(
- paths_with_repos,
- &[
- (Path::new("c.txt"), None),
- (
- Path::new("dir1/deps/dep1/src/a.txt"),
- Some(Path::new("dir1/deps/dep1").into())
- ),
- (Path::new("dir1/src/b.txt"), Some(Path::new("dir1").into())),
- ]
- );
- });
-
- let repo_update_events = Arc::new(Mutex::new(vec![]));
- tree.update(cx, |_, cx| {
- let repo_update_events = repo_update_events.clone();
- cx.subscribe(&tree, move |_, _, event, _| {
- if let Event::UpdatedGitRepositories(update) = event {
- repo_update_events.lock().push(update.clone());
- }
- })
- .detach();
- });
-
- std::fs::write(root.path().join("dir1/.git/random_new_file"), "hello").unwrap();
- tree.flush_fs_events(cx).await;
-
- assert_eq!(
- repo_update_events.lock()[0]
- .iter()
- .map(|e| e.0.clone())
- .collect::<Vec<Arc<Path>>>(),
- vec![Path::new("dir1").into()]
- );
-
- std::fs::remove_dir_all(root.path().join("dir1/.git")).unwrap();
- tree.flush_fs_events(cx).await;
-
- tree.read_with(cx, |tree, _cx| {
- let tree = tree.as_local().unwrap();
-
- assert!(tree
- .repository_for_path("dir1/src/b.txt".as_ref())
- .is_none());
- });
- }
-
- #[gpui::test]
- async fn test_git_status(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
- const IGNORE_RULE: &'static str = "**/target";
-
- let root = temp_tree(json!({
- "project": {
- "a.txt": "a",
- "b.txt": "bb",
- "c": {
- "d": {
- "e.txt": "eee"
- }
- },
- "f.txt": "ffff",
- "target": {
- "build_file": "???"
- },
- ".gitignore": IGNORE_RULE
- },
-
- }));
-
- let http_client = FakeHttpClient::with_404_response();
- let client = cx.read(|cx| Client::new(http_client, cx));
- let tree = Worktree::local(
- client,
- root.path(),
- true,
- Arc::new(RealFs),
- Default::default(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
-
- cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
- .await;
-
- const A_TXT: &'static str = "a.txt";
- const B_TXT: &'static str = "b.txt";
- const E_TXT: &'static str = "c/d/e.txt";
- const F_TXT: &'static str = "f.txt";
- const DOTGITIGNORE: &'static str = ".gitignore";
- const BUILD_FILE: &'static str = "target/build_file";
- let project_path: &Path = &Path::new("project");
-
- let work_dir = root.path().join("project");
- let mut repo = git_init(work_dir.as_path());
- repo.add_ignore_rule(IGNORE_RULE).unwrap();
- git_add(Path::new(A_TXT), &repo);
- git_add(Path::new(E_TXT), &repo);
- git_add(Path::new(DOTGITIGNORE), &repo);
- git_commit("Initial commit", &repo);
-
- tree.flush_fs_events(cx).await;
- deterministic.run_until_parked();
-
- // Check that the right git state is observed on startup
- tree.read_with(cx, |tree, _cx| {
- let snapshot = tree.snapshot();
- assert_eq!(snapshot.repository_entries.iter().count(), 1);
- let (dir, _) = snapshot.repository_entries.iter().next().unwrap();
- assert_eq!(dir.0.as_ref(), Path::new("project"));
-
- assert_eq!(
- snapshot.status_for_file(project_path.join(B_TXT)),
- Some(GitFileStatus::Added)
- );
- assert_eq!(
- snapshot.status_for_file(project_path.join(F_TXT)),
- Some(GitFileStatus::Added)
- );
- });
-
- std::fs::write(work_dir.join(A_TXT), "aa").unwrap();
-
- tree.flush_fs_events(cx).await;
- deterministic.run_until_parked();
-
- tree.read_with(cx, |tree, _cx| {
- let snapshot = tree.snapshot();
-
- assert_eq!(
- snapshot.status_for_file(project_path.join(A_TXT)),
- Some(GitFileStatus::Modified)
- );
- });
-
- git_add(Path::new(A_TXT), &repo);
- git_add(Path::new(B_TXT), &repo);
- git_commit("Committing modified and added", &repo);
- tree.flush_fs_events(cx).await;
- deterministic.run_until_parked();
-
- // Check that repo only changes are tracked
- tree.read_with(cx, |tree, _cx| {
- let snapshot = tree.snapshot();
-
- assert_eq!(
- snapshot.status_for_file(project_path.join(F_TXT)),
- Some(GitFileStatus::Added)
- );
-
- assert_eq!(snapshot.status_for_file(project_path.join(B_TXT)), None);
- assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None);
- });
-
- git_reset(0, &repo);
- git_remove_index(Path::new(B_TXT), &repo);
- git_stash(&mut repo);
- std::fs::write(work_dir.join(E_TXT), "eeee").unwrap();
- std::fs::write(work_dir.join(BUILD_FILE), "this should be ignored").unwrap();
- tree.flush_fs_events(cx).await;
- deterministic.run_until_parked();
-
- // Check that more complex repo changes are tracked
- tree.read_with(cx, |tree, _cx| {
- let snapshot = tree.snapshot();
-
- assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None);
- assert_eq!(
- snapshot.status_for_file(project_path.join(B_TXT)),
- Some(GitFileStatus::Added)
- );
- assert_eq!(
- snapshot.status_for_file(project_path.join(E_TXT)),
- Some(GitFileStatus::Modified)
- );
- });
-
- std::fs::remove_file(work_dir.join(B_TXT)).unwrap();
- std::fs::remove_dir_all(work_dir.join("c")).unwrap();
- std::fs::write(
- work_dir.join(DOTGITIGNORE),
- [IGNORE_RULE, "f.txt"].join("\n"),
- )
- .unwrap();
-
- git_add(Path::new(DOTGITIGNORE), &repo);
- git_commit("Committing modified git ignore", &repo);
-
- tree.flush_fs_events(cx).await;
- deterministic.run_until_parked();
-
- let mut renamed_dir_name = "first_directory/second_directory";
- const RENAMED_FILE: &'static str = "rf.txt";
-
- std::fs::create_dir_all(work_dir.join(renamed_dir_name)).unwrap();
- std::fs::write(
- work_dir.join(renamed_dir_name).join(RENAMED_FILE),
- "new-contents",
- )
- .unwrap();
-
- tree.flush_fs_events(cx).await;
- deterministic.run_until_parked();
-
- tree.read_with(cx, |tree, _cx| {
- let snapshot = tree.snapshot();
- assert_eq!(
- snapshot
- .status_for_file(&project_path.join(renamed_dir_name).join(RENAMED_FILE)),
- Some(GitFileStatus::Added)
- );
- });
-
- renamed_dir_name = "new_first_directory/second_directory";
-
- std::fs::rename(
- work_dir.join("first_directory"),
- work_dir.join("new_first_directory"),
- )
- .unwrap();
-
- tree.flush_fs_events(cx).await;
- deterministic.run_until_parked();
-
- tree.read_with(cx, |tree, _cx| {
- let snapshot = tree.snapshot();
-
- assert_eq!(
- snapshot.status_for_file(
- project_path
- .join(Path::new(renamed_dir_name))
- .join(RENAMED_FILE)
- ),
- Some(GitFileStatus::Added)
- );
- });
- }
-
- #[gpui::test]
- async fn test_propagate_git_statuses(cx: &mut TestAppContext) {
- let fs = FakeFs::new(cx.background());
- fs.insert_tree(
- "/root",
- json!({
- ".git": {},
- "a": {
- "b": {
- "c1.txt": "",
- "c2.txt": "",
- },
- "d": {
- "e1.txt": "",
- "e2.txt": "",
- "e3.txt": "",
- }
- },
- "f": {
- "no-status.txt": ""
- },
- "g": {
- "h1.txt": "",
- "h2.txt": ""
- },
-
- }),
- )
- .await;
-
- fs.set_status_for_repo_via_git_operation(
- &Path::new("/root/.git"),
- &[
- (Path::new("a/b/c1.txt"), GitFileStatus::Added),
- (Path::new("a/d/e2.txt"), GitFileStatus::Modified),
- (Path::new("g/h2.txt"), GitFileStatus::Conflict),
- ],
- );
-
- let http_client = FakeHttpClient::with_404_response();
- let client = cx.read(|cx| Client::new(http_client, cx));
- let tree = Worktree::local(
- client,
- Path::new("/root"),
- true,
- fs.clone(),
- Default::default(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
-
- cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
- .await;
-
- cx.foreground().run_until_parked();
- let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
-
- check_propagated_statuses(
- &snapshot,
- &[
- (Path::new(""), Some(GitFileStatus::Conflict)),
- (Path::new("a"), Some(GitFileStatus::Modified)),
- (Path::new("a/b"), Some(GitFileStatus::Added)),
- (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)),
- (Path::new("a/b/c2.txt"), None),
- (Path::new("a/d"), Some(GitFileStatus::Modified)),
- (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)),
- (Path::new("f"), None),
- (Path::new("f/no-status.txt"), None),
- (Path::new("g"), Some(GitFileStatus::Conflict)),
- (Path::new("g/h2.txt"), Some(GitFileStatus::Conflict)),
- ],
- );
-
- check_propagated_statuses(
- &snapshot,
- &[
- (Path::new("a/b"), Some(GitFileStatus::Added)),
- (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)),
- (Path::new("a/b/c2.txt"), None),
- (Path::new("a/d"), Some(GitFileStatus::Modified)),
- (Path::new("a/d/e1.txt"), None),
- (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)),
- (Path::new("f"), None),
- (Path::new("f/no-status.txt"), None),
- (Path::new("g"), Some(GitFileStatus::Conflict)),
- ],
- );
-
- check_propagated_statuses(
- &snapshot,
- &[
- (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)),
- (Path::new("a/b/c2.txt"), None),
- (Path::new("a/d/e1.txt"), None),
- (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)),
- (Path::new("f/no-status.txt"), None),
- ],
- );
-
- #[track_caller]
- fn check_propagated_statuses(
- snapshot: &Snapshot,
- expected_statuses: &[(&Path, Option<GitFileStatus>)],
- ) {
- let mut entries = expected_statuses
- .iter()
- .map(|(path, _)| snapshot.entry_for_path(path).unwrap().clone())
- .collect::<Vec<_>>();
- snapshot.propagate_git_statuses(&mut entries);
- assert_eq!(
- entries
- .iter()
- .map(|e| (e.path.as_ref(), e.git_status))
- .collect::<Vec<_>>(),
- expected_statuses
- );
- }
- }
-
- #[track_caller]
- fn git_init(path: &Path) -> git2::Repository {
- git2::Repository::init(path).expect("Failed to initialize git repository")
- }
-
- #[track_caller]
- fn git_add<P: AsRef<Path>>(path: P, repo: &git2::Repository) {
- let path = path.as_ref();
- let mut index = repo.index().expect("Failed to get index");
- index.add_path(path).expect("Failed to add a.txt");
- index.write().expect("Failed to write index");
- }
-
- #[track_caller]
- fn git_remove_index(path: &Path, repo: &git2::Repository) {
- let mut index = repo.index().expect("Failed to get index");
- index.remove_path(path).expect("Failed to add a.txt");
- index.write().expect("Failed to write index");
- }
-
- #[track_caller]
- fn git_commit(msg: &'static str, repo: &git2::Repository) {
- use git2::Signature;
-
- let signature = Signature::now("test", "test@zed.dev").unwrap();
- let oid = repo.index().unwrap().write_tree().unwrap();
- let tree = repo.find_tree(oid).unwrap();
- if let Some(head) = repo.head().ok() {
- let parent_obj = head.peel(git2::ObjectType::Commit).unwrap();
-
- let parent_commit = parent_obj.as_commit().unwrap();
-
- repo.commit(
- Some("HEAD"),
- &signature,
- &signature,
- msg,
- &tree,
- &[parent_commit],
- )
- .expect("Failed to commit with parent");
- } else {
- repo.commit(Some("HEAD"), &signature, &signature, msg, &tree, &[])
- .expect("Failed to commit");
- }
- }
-
- #[track_caller]
- fn git_stash(repo: &mut git2::Repository) {
- use git2::Signature;
-
- let signature = Signature::now("test", "test@zed.dev").unwrap();
- repo.stash_save(&signature, "N/A", None)
- .expect("Failed to stash");
- }
-
- #[track_caller]
- fn git_reset(offset: usize, repo: &git2::Repository) {
- let head = repo.head().expect("Couldn't get repo head");
- let object = head.peel(git2::ObjectType::Commit).unwrap();
- let commit = object.as_commit().unwrap();
- let new_head = commit
- .parents()
- .inspect(|parnet| {
- parnet.message();
- })
- .skip(offset)
- .next()
- .expect("Not enough history");
- repo.reset(&new_head.as_object(), git2::ResetType::Soft, None)
- .expect("Could not reset");
- }
-
- #[allow(dead_code)]
- #[track_caller]
- fn git_status(repo: &git2::Repository) -> HashMap<String, git2::Status> {
- repo.statuses(None)
- .unwrap()
- .iter()
- .map(|status| (status.path().unwrap().to_string(), status.status()))
- .collect()
- }
- }
-}
@@ -0,0 +1,1532 @@
+use crate::{
+ worktree::{Event, WorktreeHandle},
+ EntryKind, PathChange, Worktree,
+};
+use anyhow::Result;
+use client::Client;
+use fs::{FakeFs, Fs, RealFs, RemoveOptions};
+use git::GITIGNORE;
+use gpui::{executor::Deterministic, ModelContext, Task, TestAppContext};
+use parking_lot::Mutex;
+use pretty_assertions::assert_eq;
+use rand::prelude::*;
+use serde_json::json;
+use std::{
+ env,
+ fmt::Write,
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+use util::{http::FakeHttpClient, test::temp_tree, ResultExt};
+
+#[gpui::test]
+async fn test_traversal(cx: &mut TestAppContext) {
+ let fs = FakeFs::new(cx.background());
+ fs.insert_tree(
+ "/root",
+ json!({
+ ".gitignore": "a/b\n",
+ "a": {
+ "b": "",
+ "c": "",
+ }
+ }),
+ )
+ .await;
+
+ let http_client = FakeHttpClient::with_404_response();
+ let client = cx.read(|cx| Client::new(http_client, cx));
+
+ let tree = Worktree::local(
+ client,
+ Path::new("/root"),
+ true,
+ fs,
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+ .await;
+
+ tree.read_with(cx, |tree, _| {
+ assert_eq!(
+ tree.entries(false)
+ .map(|entry| entry.path.as_ref())
+ .collect::<Vec<_>>(),
+ vec![
+ Path::new(""),
+ Path::new(".gitignore"),
+ Path::new("a"),
+ Path::new("a/c"),
+ ]
+ );
+ assert_eq!(
+ tree.entries(true)
+ .map(|entry| entry.path.as_ref())
+ .collect::<Vec<_>>(),
+ vec![
+ Path::new(""),
+ Path::new(".gitignore"),
+ Path::new("a"),
+ Path::new("a/b"),
+ Path::new("a/c"),
+ ]
+ );
+ })
+}
+
+#[gpui::test]
+async fn test_descendent_entries(cx: &mut TestAppContext) {
+ let fs = FakeFs::new(cx.background());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "a": "",
+ "b": {
+ "c": {
+ "d": ""
+ },
+ "e": {}
+ },
+ "f": "",
+ "g": {
+ "h": {}
+ },
+ "i": {
+ "j": {
+ "k": ""
+ },
+ "l": {
+
+ }
+ },
+ ".gitignore": "i/j\n",
+ }),
+ )
+ .await;
+
+ let http_client = FakeHttpClient::with_404_response();
+ let client = cx.read(|cx| Client::new(http_client, cx));
+
+ let tree = Worktree::local(
+ client,
+ Path::new("/root"),
+ true,
+ fs,
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+ .await;
+
+ tree.read_with(cx, |tree, _| {
+ assert_eq!(
+ tree.descendent_entries(false, false, Path::new("b"))
+ .map(|entry| entry.path.as_ref())
+ .collect::<Vec<_>>(),
+ vec![Path::new("b/c/d"),]
+ );
+ assert_eq!(
+ tree.descendent_entries(true, false, Path::new("b"))
+ .map(|entry| entry.path.as_ref())
+ .collect::<Vec<_>>(),
+ vec![
+ Path::new("b"),
+ Path::new("b/c"),
+ Path::new("b/c/d"),
+ Path::new("b/e"),
+ ]
+ );
+
+ assert_eq!(
+ tree.descendent_entries(false, false, Path::new("g"))
+ .map(|entry| entry.path.as_ref())
+ .collect::<Vec<_>>(),
+ Vec::<PathBuf>::new()
+ );
+ assert_eq!(
+ tree.descendent_entries(true, false, Path::new("g"))
+ .map(|entry| entry.path.as_ref())
+ .collect::<Vec<_>>(),
+ vec![Path::new("g"), Path::new("g/h"),]
+ );
+
+ assert_eq!(
+ tree.descendent_entries(false, false, Path::new("i"))
+ .map(|entry| entry.path.as_ref())
+ .collect::<Vec<_>>(),
+ Vec::<PathBuf>::new()
+ );
+ assert_eq!(
+ tree.descendent_entries(false, true, Path::new("i"))
+ .map(|entry| entry.path.as_ref())
+ .collect::<Vec<_>>(),
+ vec![Path::new("i/j/k")]
+ );
+ assert_eq!(
+ tree.descendent_entries(true, false, Path::new("i"))
+ .map(|entry| entry.path.as_ref())
+ .collect::<Vec<_>>(),
+ vec![Path::new("i"), Path::new("i/l"),]
+ );
+ })
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_circular_symlinks(executor: Arc<Deterministic>, cx: &mut TestAppContext) {
+ let fs = FakeFs::new(cx.background());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "lib": {
+ "a": {
+ "a.txt": ""
+ },
+ "b": {
+ "b.txt": ""
+ }
+ }
+ }),
+ )
+ .await;
+ fs.insert_symlink("/root/lib/a/lib", "..".into()).await;
+ fs.insert_symlink("/root/lib/b/lib", "..".into()).await;
+
+ let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
+ let tree = Worktree::local(
+ client,
+ Path::new("/root"),
+ true,
+ fs.clone(),
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+
+ cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+ .await;
+
+ tree.read_with(cx, |tree, _| {
+ assert_eq!(
+ tree.entries(false)
+ .map(|entry| entry.path.as_ref())
+ .collect::<Vec<_>>(),
+ vec![
+ Path::new(""),
+ Path::new("lib"),
+ Path::new("lib/a"),
+ Path::new("lib/a/a.txt"),
+ Path::new("lib/a/lib"),
+ Path::new("lib/b"),
+ Path::new("lib/b/b.txt"),
+ Path::new("lib/b/lib"),
+ ]
+ );
+ });
+
+ fs.rename(
+ Path::new("/root/lib/a/lib"),
+ Path::new("/root/lib/a/lib-2"),
+ Default::default(),
+ )
+ .await
+ .unwrap();
+ executor.run_until_parked();
+ tree.read_with(cx, |tree, _| {
+ assert_eq!(
+ tree.entries(false)
+ .map(|entry| entry.path.as_ref())
+ .collect::<Vec<_>>(),
+ vec![
+ Path::new(""),
+ Path::new("lib"),
+ Path::new("lib/a"),
+ Path::new("lib/a/a.txt"),
+ Path::new("lib/a/lib-2"),
+ Path::new("lib/b"),
+ Path::new("lib/b/b.txt"),
+ Path::new("lib/b/lib"),
+ ]
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_rescan_with_gitignore(cx: &mut TestAppContext) {
+ // .gitignores are handled explicitly by Zed and do not use the git
+ // machinery that the git_tests module checks
+ let parent_dir = temp_tree(json!({
+ ".gitignore": "ancestor-ignored-file1\nancestor-ignored-file2\n",
+ "tree": {
+ ".git": {},
+ ".gitignore": "ignored-dir\n",
+ "tracked-dir": {
+ "tracked-file1": "",
+ "ancestor-ignored-file1": "",
+ },
+ "ignored-dir": {
+ "ignored-file1": ""
+ }
+ }
+ }));
+ let dir = parent_dir.path().join("tree");
+
+ let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
+
+ let tree = Worktree::local(
+ client,
+ dir.as_path(),
+ true,
+ Arc::new(RealFs),
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+ .await;
+ tree.flush_fs_events(cx).await;
+ cx.read(|cx| {
+ let tree = tree.read(cx);
+ assert!(
+ !tree
+ .entry_for_path("tracked-dir/tracked-file1")
+ .unwrap()
+ .is_ignored
+ );
+ assert!(
+ tree.entry_for_path("tracked-dir/ancestor-ignored-file1")
+ .unwrap()
+ .is_ignored
+ );
+ assert!(
+ tree.entry_for_path("ignored-dir/ignored-file1")
+ .unwrap()
+ .is_ignored
+ );
+ });
+
+ std::fs::write(dir.join("tracked-dir/tracked-file2"), "").unwrap();
+ std::fs::write(dir.join("tracked-dir/ancestor-ignored-file2"), "").unwrap();
+ std::fs::write(dir.join("ignored-dir/ignored-file2"), "").unwrap();
+ tree.flush_fs_events(cx).await;
+ cx.read(|cx| {
+ let tree = tree.read(cx);
+ assert!(
+ !tree
+ .entry_for_path("tracked-dir/tracked-file2")
+ .unwrap()
+ .is_ignored
+ );
+ assert!(
+ tree.entry_for_path("tracked-dir/ancestor-ignored-file2")
+ .unwrap()
+ .is_ignored
+ );
+ assert!(
+ tree.entry_for_path("ignored-dir/ignored-file2")
+ .unwrap()
+ .is_ignored
+ );
+ assert!(tree.entry_for_path(".git").unwrap().is_ignored);
+ });
+}
+
+#[gpui::test]
+async fn test_write_file(cx: &mut TestAppContext) {
+ let dir = temp_tree(json!({
+ ".git": {},
+ ".gitignore": "ignored-dir\n",
+ "tracked-dir": {},
+ "ignored-dir": {}
+ }));
+
+ let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
+
+ let tree = Worktree::local(
+ client,
+ dir.path(),
+ true,
+ Arc::new(RealFs),
+ Default::default(),
+ &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.update(cx, |tree, cx| {
+ tree.as_local().unwrap().write_file(
+ Path::new("tracked-dir/file.txt"),
+ "hello".into(),
+ Default::default(),
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ tree.update(cx, |tree, cx| {
+ tree.as_local().unwrap().write_file(
+ Path::new("ignored-dir/file.txt"),
+ "world".into(),
+ Default::default(),
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ tree.read_with(cx, |tree, _| {
+ let tracked = tree.entry_for_path("tracked-dir/file.txt").unwrap();
+ let ignored = tree.entry_for_path("ignored-dir/file.txt").unwrap();
+ assert!(!tracked.is_ignored);
+ assert!(ignored.is_ignored);
+ });
+}
+
+#[gpui::test(iterations = 30)]
+async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) {
+ let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
+
+ let fs = FakeFs::new(cx.background());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "b": {},
+ "c": {},
+ "d": {},
+ }),
+ )
+ .await;
+
+ let tree = Worktree::local(
+ client,
+ "/root".as_ref(),
+ true,
+ fs,
+ Default::default(),
+ &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()));
+ let _ = tree.observe_updates(0, cx, {
+ let snapshot = snapshot.clone();
+ move |update| {
+ snapshot.lock().apply_remote_update(update).unwrap();
+ async { true }
+ }
+ });
+ snapshot
+ });
+
+ let entry = tree
+ .update(cx, |tree, cx| {
+ tree.as_local_mut()
+ .unwrap()
+ .create_entry("a/e".as_ref(), true, cx)
+ })
+ .await
+ .unwrap();
+ assert!(entry.is_dir());
+
+ cx.foreground().run_until_parked();
+ tree.read_with(cx, |tree, _| {
+ assert_eq!(tree.entry_for_path("a/e").unwrap().kind, EntryKind::Dir);
+ });
+
+ let snapshot2 = tree.update(cx, |tree, _| tree.as_local().unwrap().snapshot());
+ assert_eq!(
+ snapshot1.lock().entries(true).collect::<Vec<_>>(),
+ snapshot2.entries(true).collect::<Vec<_>>()
+ );
+}
+
+#[gpui::test(iterations = 100)]
+async fn test_random_worktree_operations_during_initial_scan(
+ cx: &mut TestAppContext,
+ mut rng: StdRng,
+) {
+ 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("/test");
+ let fs = FakeFs::new(cx.background()) 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 client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
+ let worktree = Worktree::local(
+ client.clone(),
+ root_dir,
+ true,
+ fs.clone(),
+ Default::default(),
+ &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);
+
+ let _ = 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()
+ });
+
+ if rng.gen_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.foreground().run_until_parked();
+
+ let final_snapshot = worktree.read_with(cx, |tree, _| {
+ let tree = tree.as_local().unwrap();
+ let snapshot = tree.snapshot();
+ snapshot.check_invariants();
+ snapshot
+ });
+
+ 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())
+ .unwrap();
+ }
+ }
+
+ assert_eq!(
+ updated_snapshot.entries(true).collect::<Vec<_>>(),
+ final_snapshot.entries(true).collect::<Vec<_>>(),
+ "wrong updates after snapshot {i}: {snapshot:#?} {updates:#?}",
+ );
+ }
+}
+
+#[gpui::test(iterations = 100)]
+async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) {
+ 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("/test");
+ let fs = FakeFs::new(cx.background()) 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 client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
+ let worktree = Worktree::local(
+ client.clone(),
+ root_dir,
+ true,
+ fs.clone(),
+ Default::default(),
+ &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);
+
+ let _ = 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.gen_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.gen_bool(0.3) {
+ let len = rng.gen_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.foreground().run_until_parked();
+ if rng.gen_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.foreground().run_until_parked();
+ let snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot());
+ snapshot.check_invariants();
+
+ {
+ let new_worktree = Worktree::local(
+ client.clone(),
+ root_dir,
+ true,
+ fs.clone(),
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ new_worktree
+ .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete())
+ .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)
+ );
+ }
+
+ 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()).unwrap();
+ }
+ }
+
+ assert_eq!(
+ prev_snapshot.entries(true).collect::<Vec<_>>(),
+ snapshot.entries(true).collect::<Vec<_>>(),
+ "wrong updates after snapshot {i}: {updates:#?}",
+ );
+ }
+}
+
+// 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 ModelContext<Worktree>) {
+ let mut entries = tree.entries(true).cloned().collect::<Vec<_>>();
+ cx.subscribe(&cx.handle(), 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::Loaded => entries.insert(ix, entry.unwrap()),
+ 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 => {
+ 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).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 ModelContext<Worktree>,
+) -> Task<Result<()>> {
+ log::info!("mutating worktree");
+ let worktree = worktree.as_local_mut().unwrap();
+ let snapshot = worktree.snapshot();
+ let entry = snapshot.entries(false).choose(rng).unwrap();
+
+ match rng.gen_range(0_u32..100) {
+ 0..=33 if entry.path.as_ref() != Path::new("") => {
+ log::info!("deleting entry {:?} ({})", entry.path, entry.id.0);
+ worktree.delete_entry(entry.id, cx).unwrap()
+ }
+ ..=66 if entry.path.as_ref() != Path::new("") => {
+ let other_entry = snapshot.entries(false).choose(rng).unwrap();
+ let new_parent_path = if other_entry.is_dir() {
+ other_entry.path.clone()
+ } else {
+ other_entry.path.parent().unwrap().into()
+ };
+ let mut new_path = new_parent_path.join(gen_name(rng));
+ if new_path.starts_with(&entry.path) {
+ new_path = gen_name(rng).into();
+ }
+
+ log::info!(
+ "renaming entry {:?} ({}) to {:?}",
+ entry.path,
+ entry.id.0,
+ new_path
+ );
+ let task = worktree.rename_entry(entry.id, new_path, cx).unwrap();
+ cx.foreground().spawn(async move {
+ task.await?;
+ Ok(())
+ })
+ }
+ _ => {
+ let task = if entry.is_dir() {
+ let child_path = entry.path.join(gen_name(rng));
+ let is_dir = rng.gen_bool(0.3);
+ log::info!(
+ "creating {} at {:?}",
+ if is_dir { "dir" } else { "file" },
+ child_path,
+ );
+ worktree.create_entry(child_path, is_dir, cx)
+ } else {
+ log::info!("overwriting file {:?} ({})", entry.path, entry.id.0);
+ worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx)
+ };
+ cx.foreground().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.gen_bool(insertion_probability) {
+ let path = dirs.choose(rng).unwrap();
+ let new_path = path.join(gen_name(rng));
+
+ if rng.gen() {
+ 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.gen_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.gen_range(0..=subfiles.len());
+ subfiles.choose_multiple(rng, len)
+ };
+ let dirs_to_ignore = {
+ let len = rng.gen_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.gen();
+ 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.gen_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(gen_name(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,
+ },
+ )
+ .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 gen_name(rng: &mut impl Rng) -> String {
+ (0..6)
+ .map(|_| rng.sample(rand::distributions::Alphanumeric))
+ .map(char::from)
+ .collect()
+}
+
+mod git_tests {
+ use crate::{worktree::WorktreeHandle, Snapshot};
+
+ use super::*;
+ use collections::HashMap;
+ use fs::repository::GitFileStatus;
+ use pretty_assertions::assert_eq;
+
+ #[gpui::test]
+ async fn test_rename_work_directory(cx: &mut TestAppContext) {
+ let root = temp_tree(json!({
+ "projects": {
+ "project1": {
+ "a": "",
+ "b": "",
+ }
+ },
+
+ }));
+ let root_path = root.path();
+
+ let http_client = FakeHttpClient::with_404_response();
+ let client = cx.read(|cx| Client::new(http_client, cx));
+ let tree = Worktree::local(
+ client,
+ root_path,
+ true,
+ Arc::new(RealFs),
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+
+ let repo = git_init(&root_path.join("projects/project1"));
+ git_add("a", &repo);
+ git_commit("init", &repo);
+ std::fs::write(root_path.join("projects/project1/a"), "aa").ok();
+
+ cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+ .await;
+
+ tree.flush_fs_events(cx).await;
+
+ cx.read(|cx| {
+ let tree = tree.read(cx);
+ let (work_dir, _) = tree.repositories().next().unwrap();
+ assert_eq!(work_dir.as_ref(), Path::new("projects/project1"));
+ assert_eq!(
+ tree.status_for_file(Path::new("projects/project1/a")),
+ Some(GitFileStatus::Modified)
+ );
+ assert_eq!(
+ tree.status_for_file(Path::new("projects/project1/b")),
+ Some(GitFileStatus::Added)
+ );
+ });
+
+ std::fs::rename(
+ root_path.join("projects/project1"),
+ root_path.join("projects/project2"),
+ )
+ .ok();
+ tree.flush_fs_events(cx).await;
+
+ cx.read(|cx| {
+ let tree = tree.read(cx);
+ let (work_dir, _) = tree.repositories().next().unwrap();
+ assert_eq!(work_dir.as_ref(), Path::new("projects/project2"));
+ assert_eq!(
+ tree.status_for_file(Path::new("projects/project2/a")),
+ Some(GitFileStatus::Modified)
+ );
+ assert_eq!(
+ tree.status_for_file(Path::new("projects/project2/b")),
+ Some(GitFileStatus::Added)
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_git_repository_for_path(cx: &mut TestAppContext) {
+ let root = temp_tree(json!({
+ "c.txt": "",
+ "dir1": {
+ ".git": {},
+ "deps": {
+ "dep1": {
+ ".git": {},
+ "src": {
+ "a.txt": ""
+ }
+ }
+ },
+ "src": {
+ "b.txt": ""
+ }
+ },
+ }));
+
+ let http_client = FakeHttpClient::with_404_response();
+ let client = cx.read(|cx| Client::new(http_client, cx));
+ let tree = Worktree::local(
+ client,
+ root.path(),
+ true,
+ Arc::new(RealFs),
+ Default::default(),
+ &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, _cx| {
+ let tree = tree.as_local().unwrap();
+
+ assert!(tree.repository_for_path("c.txt".as_ref()).is_none());
+
+ let entry = tree.repository_for_path("dir1/src/b.txt".as_ref()).unwrap();
+ assert_eq!(
+ entry
+ .work_directory(tree)
+ .map(|directory| directory.as_ref().to_owned()),
+ Some(Path::new("dir1").to_owned())
+ );
+
+ let entry = tree
+ .repository_for_path("dir1/deps/dep1/src/a.txt".as_ref())
+ .unwrap();
+ assert_eq!(
+ entry
+ .work_directory(tree)
+ .map(|directory| directory.as_ref().to_owned()),
+ Some(Path::new("dir1/deps/dep1").to_owned())
+ );
+
+ let entries = tree.files(false, 0);
+
+ let paths_with_repos = tree
+ .entries_with_repositories(entries)
+ .map(|(entry, repo)| {
+ (
+ entry.path.as_ref(),
+ repo.and_then(|repo| {
+ repo.work_directory(&tree)
+ .map(|work_directory| work_directory.0.to_path_buf())
+ }),
+ )
+ })
+ .collect::<Vec<_>>();
+
+ assert_eq!(
+ paths_with_repos,
+ &[
+ (Path::new("c.txt"), None),
+ (
+ Path::new("dir1/deps/dep1/src/a.txt"),
+ Some(Path::new("dir1/deps/dep1").into())
+ ),
+ (Path::new("dir1/src/b.txt"), Some(Path::new("dir1").into())),
+ ]
+ );
+ });
+
+ let repo_update_events = Arc::new(Mutex::new(vec![]));
+ tree.update(cx, |_, cx| {
+ let repo_update_events = repo_update_events.clone();
+ cx.subscribe(&tree, move |_, _, event, _| {
+ if let Event::UpdatedGitRepositories(update) = event {
+ repo_update_events.lock().push(update.clone());
+ }
+ })
+ .detach();
+ });
+
+ std::fs::write(root.path().join("dir1/.git/random_new_file"), "hello").unwrap();
+ tree.flush_fs_events(cx).await;
+
+ assert_eq!(
+ repo_update_events.lock()[0]
+ .iter()
+ .map(|e| e.0.clone())
+ .collect::<Vec<Arc<Path>>>(),
+ vec![Path::new("dir1").into()]
+ );
+
+ std::fs::remove_dir_all(root.path().join("dir1/.git")).unwrap();
+ tree.flush_fs_events(cx).await;
+
+ tree.read_with(cx, |tree, _cx| {
+ let tree = tree.as_local().unwrap();
+
+ assert!(tree
+ .repository_for_path("dir1/src/b.txt".as_ref())
+ .is_none());
+ });
+ }
+
+ #[gpui::test]
+ async fn test_git_status(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
+ const IGNORE_RULE: &'static str = "**/target";
+
+ let root = temp_tree(json!({
+ "project": {
+ "a.txt": "a",
+ "b.txt": "bb",
+ "c": {
+ "d": {
+ "e.txt": "eee"
+ }
+ },
+ "f.txt": "ffff",
+ "target": {
+ "build_file": "???"
+ },
+ ".gitignore": IGNORE_RULE
+ },
+
+ }));
+
+ let http_client = FakeHttpClient::with_404_response();
+ let client = cx.read(|cx| Client::new(http_client, cx));
+ let tree = Worktree::local(
+ client,
+ root.path(),
+ true,
+ Arc::new(RealFs),
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+
+ cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+ .await;
+
+ const A_TXT: &'static str = "a.txt";
+ const B_TXT: &'static str = "b.txt";
+ const E_TXT: &'static str = "c/d/e.txt";
+ const F_TXT: &'static str = "f.txt";
+ const DOTGITIGNORE: &'static str = ".gitignore";
+ const BUILD_FILE: &'static str = "target/build_file";
+ let project_path: &Path = &Path::new("project");
+
+ let work_dir = root.path().join("project");
+ let mut repo = git_init(work_dir.as_path());
+ repo.add_ignore_rule(IGNORE_RULE).unwrap();
+ git_add(Path::new(A_TXT), &repo);
+ git_add(Path::new(E_TXT), &repo);
+ git_add(Path::new(DOTGITIGNORE), &repo);
+ git_commit("Initial commit", &repo);
+
+ tree.flush_fs_events(cx).await;
+ deterministic.run_until_parked();
+
+ // Check that the right git state is observed on startup
+ tree.read_with(cx, |tree, _cx| {
+ let snapshot = tree.snapshot();
+ assert_eq!(snapshot.repositories().count(), 1);
+ let (dir, _) = snapshot.repositories().next().unwrap();
+ assert_eq!(dir.as_ref(), Path::new("project"));
+
+ assert_eq!(
+ snapshot.status_for_file(project_path.join(B_TXT)),
+ Some(GitFileStatus::Added)
+ );
+ assert_eq!(
+ snapshot.status_for_file(project_path.join(F_TXT)),
+ Some(GitFileStatus::Added)
+ );
+ });
+
+ std::fs::write(work_dir.join(A_TXT), "aa").unwrap();
+
+ tree.flush_fs_events(cx).await;
+ deterministic.run_until_parked();
+
+ tree.read_with(cx, |tree, _cx| {
+ let snapshot = tree.snapshot();
+
+ assert_eq!(
+ snapshot.status_for_file(project_path.join(A_TXT)),
+ Some(GitFileStatus::Modified)
+ );
+ });
+
+ git_add(Path::new(A_TXT), &repo);
+ git_add(Path::new(B_TXT), &repo);
+ git_commit("Committing modified and added", &repo);
+ tree.flush_fs_events(cx).await;
+ deterministic.run_until_parked();
+
+ // Check that repo only changes are tracked
+ tree.read_with(cx, |tree, _cx| {
+ let snapshot = tree.snapshot();
+
+ assert_eq!(
+ snapshot.status_for_file(project_path.join(F_TXT)),
+ Some(GitFileStatus::Added)
+ );
+
+ assert_eq!(snapshot.status_for_file(project_path.join(B_TXT)), None);
+ assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None);
+ });
+
+ git_reset(0, &repo);
+ git_remove_index(Path::new(B_TXT), &repo);
+ git_stash(&mut repo);
+ std::fs::write(work_dir.join(E_TXT), "eeee").unwrap();
+ std::fs::write(work_dir.join(BUILD_FILE), "this should be ignored").unwrap();
+ tree.flush_fs_events(cx).await;
+ deterministic.run_until_parked();
+
+ // Check that more complex repo changes are tracked
+ tree.read_with(cx, |tree, _cx| {
+ let snapshot = tree.snapshot();
+
+ assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None);
+ assert_eq!(
+ snapshot.status_for_file(project_path.join(B_TXT)),
+ Some(GitFileStatus::Added)
+ );
+ assert_eq!(
+ snapshot.status_for_file(project_path.join(E_TXT)),
+ Some(GitFileStatus::Modified)
+ );
+ });
+
+ std::fs::remove_file(work_dir.join(B_TXT)).unwrap();
+ std::fs::remove_dir_all(work_dir.join("c")).unwrap();
+ std::fs::write(
+ work_dir.join(DOTGITIGNORE),
+ [IGNORE_RULE, "f.txt"].join("\n"),
+ )
+ .unwrap();
+
+ git_add(Path::new(DOTGITIGNORE), &repo);
+ git_commit("Committing modified git ignore", &repo);
+
+ tree.flush_fs_events(cx).await;
+ deterministic.run_until_parked();
+
+ let mut renamed_dir_name = "first_directory/second_directory";
+ const RENAMED_FILE: &'static str = "rf.txt";
+
+ std::fs::create_dir_all(work_dir.join(renamed_dir_name)).unwrap();
+ std::fs::write(
+ work_dir.join(renamed_dir_name).join(RENAMED_FILE),
+ "new-contents",
+ )
+ .unwrap();
+
+ tree.flush_fs_events(cx).await;
+ deterministic.run_until_parked();
+
+ tree.read_with(cx, |tree, _cx| {
+ let snapshot = tree.snapshot();
+ assert_eq!(
+ snapshot.status_for_file(&project_path.join(renamed_dir_name).join(RENAMED_FILE)),
+ Some(GitFileStatus::Added)
+ );
+ });
+
+ renamed_dir_name = "new_first_directory/second_directory";
+
+ std::fs::rename(
+ work_dir.join("first_directory"),
+ work_dir.join("new_first_directory"),
+ )
+ .unwrap();
+
+ tree.flush_fs_events(cx).await;
+ deterministic.run_until_parked();
+
+ tree.read_with(cx, |tree, _cx| {
+ let snapshot = tree.snapshot();
+
+ assert_eq!(
+ snapshot.status_for_file(
+ project_path
+ .join(Path::new(renamed_dir_name))
+ .join(RENAMED_FILE)
+ ),
+ Some(GitFileStatus::Added)
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_propagate_git_statuses(cx: &mut TestAppContext) {
+ let fs = FakeFs::new(cx.background());
+ fs.insert_tree(
+ "/root",
+ json!({
+ ".git": {},
+ "a": {
+ "b": {
+ "c1.txt": "",
+ "c2.txt": "",
+ },
+ "d": {
+ "e1.txt": "",
+ "e2.txt": "",
+ "e3.txt": "",
+ }
+ },
+ "f": {
+ "no-status.txt": ""
+ },
+ "g": {
+ "h1.txt": "",
+ "h2.txt": ""
+ },
+
+ }),
+ )
+ .await;
+
+ fs.set_status_for_repo_via_git_operation(
+ &Path::new("/root/.git"),
+ &[
+ (Path::new("a/b/c1.txt"), GitFileStatus::Added),
+ (Path::new("a/d/e2.txt"), GitFileStatus::Modified),
+ (Path::new("g/h2.txt"), GitFileStatus::Conflict),
+ ],
+ );
+
+ let http_client = FakeHttpClient::with_404_response();
+ let client = cx.read(|cx| Client::new(http_client, cx));
+ let tree = Worktree::local(
+ client,
+ Path::new("/root"),
+ true,
+ fs.clone(),
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+
+ cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+ .await;
+
+ cx.foreground().run_until_parked();
+ let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
+
+ check_propagated_statuses(
+ &snapshot,
+ &[
+ (Path::new(""), Some(GitFileStatus::Conflict)),
+ (Path::new("a"), Some(GitFileStatus::Modified)),
+ (Path::new("a/b"), Some(GitFileStatus::Added)),
+ (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)),
+ (Path::new("a/b/c2.txt"), None),
+ (Path::new("a/d"), Some(GitFileStatus::Modified)),
+ (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)),
+ (Path::new("f"), None),
+ (Path::new("f/no-status.txt"), None),
+ (Path::new("g"), Some(GitFileStatus::Conflict)),
+ (Path::new("g/h2.txt"), Some(GitFileStatus::Conflict)),
+ ],
+ );
+
+ check_propagated_statuses(
+ &snapshot,
+ &[
+ (Path::new("a/b"), Some(GitFileStatus::Added)),
+ (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)),
+ (Path::new("a/b/c2.txt"), None),
+ (Path::new("a/d"), Some(GitFileStatus::Modified)),
+ (Path::new("a/d/e1.txt"), None),
+ (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)),
+ (Path::new("f"), None),
+ (Path::new("f/no-status.txt"), None),
+ (Path::new("g"), Some(GitFileStatus::Conflict)),
+ ],
+ );
+
+ check_propagated_statuses(
+ &snapshot,
+ &[
+ (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)),
+ (Path::new("a/b/c2.txt"), None),
+ (Path::new("a/d/e1.txt"), None),
+ (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)),
+ (Path::new("f/no-status.txt"), None),
+ ],
+ );
+
+ #[track_caller]
+ fn check_propagated_statuses(
+ snapshot: &Snapshot,
+ expected_statuses: &[(&Path, Option<GitFileStatus>)],
+ ) {
+ let mut entries = expected_statuses
+ .iter()
+ .map(|(path, _)| snapshot.entry_for_path(path).unwrap().clone())
+ .collect::<Vec<_>>();
+ snapshot.propagate_git_statuses(&mut entries);
+ assert_eq!(
+ entries
+ .iter()
+ .map(|e| (e.path.as_ref(), e.git_status))
+ .collect::<Vec<_>>(),
+ expected_statuses
+ );
+ }
+ }
+
+ #[track_caller]
+ fn git_init(path: &Path) -> git2::Repository {
+ git2::Repository::init(path).expect("Failed to initialize git repository")
+ }
+
+ #[track_caller]
+ fn git_add<P: AsRef<Path>>(path: P, repo: &git2::Repository) {
+ let path = path.as_ref();
+ let mut index = repo.index().expect("Failed to get index");
+ index.add_path(path).expect("Failed to add a.txt");
+ index.write().expect("Failed to write index");
+ }
+
+ #[track_caller]
+ fn git_remove_index(path: &Path, repo: &git2::Repository) {
+ let mut index = repo.index().expect("Failed to get index");
+ index.remove_path(path).expect("Failed to add a.txt");
+ index.write().expect("Failed to write index");
+ }
+
+ #[track_caller]
+ fn git_commit(msg: &'static str, repo: &git2::Repository) {
+ use git2::Signature;
+
+ let signature = Signature::now("test", "test@zed.dev").unwrap();
+ let oid = repo.index().unwrap().write_tree().unwrap();
+ let tree = repo.find_tree(oid).unwrap();
+ if let Some(head) = repo.head().ok() {
+ let parent_obj = head.peel(git2::ObjectType::Commit).unwrap();
+
+ let parent_commit = parent_obj.as_commit().unwrap();
+
+ repo.commit(
+ Some("HEAD"),
+ &signature,
+ &signature,
+ msg,
+ &tree,
+ &[parent_commit],
+ )
+ .expect("Failed to commit with parent");
+ } else {
+ repo.commit(Some("HEAD"), &signature, &signature, msg, &tree, &[])
+ .expect("Failed to commit");
+ }
+ }
+
+ #[track_caller]
+ fn git_stash(repo: &mut git2::Repository) {
+ use git2::Signature;
+
+ let signature = Signature::now("test", "test@zed.dev").unwrap();
+ repo.stash_save(&signature, "N/A", None)
+ .expect("Failed to stash");
+ }
+
+ #[track_caller]
+ fn git_reset(offset: usize, repo: &git2::Repository) {
+ let head = repo.head().expect("Couldn't get repo head");
+ let object = head.peel(git2::ObjectType::Commit).unwrap();
+ let commit = object.as_commit().unwrap();
+ let new_head = commit
+ .parents()
+ .inspect(|parnet| {
+ parnet.message();
+ })
+ .skip(offset)
+ .next()
+ .expect("Not enough history");
+ repo.reset(&new_head.as_object(), git2::ResetType::Soft, None)
+ .expect("Could not reset");
+ }
+
+ #[allow(dead_code)]
+ #[track_caller]
+ fn git_status(repo: &git2::Repository) -> HashMap<String, git2::Status> {
+ repo.statuses(None)
+ .unwrap()
+ .iter()
+ .map(|status| (status.path().unwrap().to_string(), status.status()))
+ .collect()
+ }
+}