From c17dbab6f19974c9d71ed427ec5c9772b21d12f2 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 13 Jun 2023 10:22:14 -0700 Subject: [PATCH 1/2] Move worktree tests to their own file --- crates/project/src/project.rs | 2 + crates/project/src/worktree.rs | 1682 ++------------------------ crates/project/src/worktree_tests.rs | 1532 +++++++++++++++++++++++ 3 files changed, 1614 insertions(+), 1602 deletions(-) create mode 100644 crates/project/src/worktree_tests.rs diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 0ac50e7106..5069c805b7 100644 --- a/crates/project/src/project.rs +++ b/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}; diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index ee190e1a31..561da2c292 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -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); +pub struct RepositoryWorkDirectory(pub(crate) Arc); impl Default for RepositoryWorkDirectory { fn default() -> Self { @@ -212,7 +212,7 @@ pub struct LocalSnapshot { git_repositories: TreeMap, } -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::>(), + self.entries_by_id + .cursor::<()>() + .map(|e| (&e.path, e.id)) + .collect::>() + .into_iter() + .collect::>(), + "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::>(); + assert_eq!(bfs_paths, dfs_paths_via_iter); + + let dfs_paths_via_traversal = self + .entries(true) + .map(|e| e.path.as_ref()) + .collect::>(); + 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![ - 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![ - 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![Path::new("b/c/d"),] - ); - assert_eq!( - tree.descendent_entries(true, false, Path::new("b")) - .map(|entry| entry.path.as_ref()) - .collect::>(), - 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::::new() - ); - assert_eq!( - tree.descendent_entries(true, false, Path::new("g")) - .map(|entry| entry.path.as_ref()) - .collect::>(), - 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::::new() - ); - assert_eq!( - tree.descendent_entries(false, true, Path::new("i")) - .map(|entry| entry.path.as_ref()) - .collect::>(), - vec![Path::new("i/j/k")] - ); - assert_eq!( - tree.descendent_entries(true, false, Path::new("i")) - .map(|entry| entry.path.as_ref()) - .collect::>(), - vec![Path::new("i"), Path::new("i/l"),] - ); - }) - } - - #[gpui::test(iterations = 10)] - async fn test_circular_symlinks(executor: Arc, 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![ - 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![ - 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::>(), - snapshot2.entries(true).collect::>() - ); - } - - #[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; - 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::>(), - final_snapshot.entries(true).collect::>(), - "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; - 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::>(), - snapshot.entries(true).collect::>(), - "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) { - let mut entries = tree.entries(true).cloned().collect::>(); - 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::>(); - assert_eq!(entries, new_entries, "incorrect changes: {:?}", changes); - } - }) - .detach(); - } - - fn randomly_mutate_worktree( - worktree: &mut Worktree, - rng: &mut impl Rng, - cx: &mut ModelContext, - ) -> Task> { - 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, - 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::>(); - let subfiles = files - .iter() - .filter(|d| d.starts_with(&ignore_dir_path)) - .cloned() - .collect::>(); - 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::>(), - self.entries_by_id - .cursor::<()>() - .map(|e| (&e.path, e.id)) - .collect::>() - .into_iter() - .collect::>(), - "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::>(); - assert_eq!(bfs_paths, dfs_paths_via_iter); - - let dfs_paths_via_traversal = self - .entries(true) - .map(|e| e.path.as_ref()) - .collect::>(); - 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::>(); - - 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![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, 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)], - ) { - let mut entries = expected_statuses - .iter() - .map(|(path, _)| snapshot.entry_for_path(path).unwrap().clone()) - .collect::>(); - snapshot.propagate_git_statuses(&mut entries); - assert_eq!( - entries - .iter() - .map(|e| (e.path.as_ref(), e.git_status)) - .collect::>(), - 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>(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 { - repo.statuses(None) - .unwrap() - .iter() - .map(|status| (status.path().unwrap().to_string(), status.status())) - .collect() - } - } -} diff --git a/crates/project/src/worktree_tests.rs b/crates/project/src/worktree_tests.rs new file mode 100644 index 0000000000..d32628956f --- /dev/null +++ b/crates/project/src/worktree_tests.rs @@ -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![ + 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![ + 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![Path::new("b/c/d"),] + ); + assert_eq!( + tree.descendent_entries(true, false, Path::new("b")) + .map(|entry| entry.path.as_ref()) + .collect::>(), + 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::::new() + ); + assert_eq!( + tree.descendent_entries(true, false, Path::new("g")) + .map(|entry| entry.path.as_ref()) + .collect::>(), + 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::::new() + ); + assert_eq!( + tree.descendent_entries(false, true, Path::new("i")) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![Path::new("i/j/k")] + ); + assert_eq!( + tree.descendent_entries(true, false, Path::new("i")) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![Path::new("i"), Path::new("i/l"),] + ); + }) +} + +#[gpui::test(iterations = 10)] +async fn test_circular_symlinks(executor: Arc, 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![ + 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![ + 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::>(), + snapshot2.entries(true).collect::>() + ); +} + +#[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; + 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::>(), + final_snapshot.entries(true).collect::>(), + "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; + 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::>(), + snapshot.entries(true).collect::>(), + "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) { + let mut entries = tree.entries(true).cloned().collect::>(); + 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::>(); + assert_eq!(entries, new_entries, "incorrect changes: {:?}", changes); + } + }) + .detach(); +} + +fn randomly_mutate_worktree( + worktree: &mut Worktree, + rng: &mut impl Rng, + cx: &mut ModelContext, +) -> Task> { + 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, + 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::>(); + let subfiles = files + .iter() + .filter(|d| d.starts_with(&ignore_dir_path)) + .cloned() + .collect::>(); + 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::>(); + + 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![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, 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)], + ) { + let mut entries = expected_statuses + .iter() + .map(|(path, _)| snapshot.entry_for_path(path).unwrap().clone()) + .collect::>(); + snapshot.propagate_git_statuses(&mut entries); + assert_eq!( + entries + .iter() + .map(|e| (e.path.as_ref(), e.git_status)) + .collect::>(), + 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>(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 { + repo.statuses(None) + .unwrap() + .iter() + .map(|status| (status.path().unwrap().to_string(), status.status())) + .collect() + } +} From 5b6d1a27ffcc7ae44423270e84c0a5152d51134d Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 13 Jun 2023 10:31:29 -0700 Subject: [PATCH 2/2] Flatten worktree tests module structure --- crates/project/src/worktree_tests.rs | 1167 +++++++++++++------------- 1 file changed, 579 insertions(+), 588 deletions(-) diff --git a/crates/project/src/worktree_tests.rs b/crates/project/src/worktree_tests.rs index d32628956f..3abf660282 100644 --- a/crates/project/src/worktree_tests.rs +++ b/crates/project/src/worktree_tests.rs @@ -1,10 +1,10 @@ use crate::{ - worktree::{Event, WorktreeHandle}, + worktree::{Event, Snapshot, WorktreeHandle}, EntryKind, PathChange, Worktree, }; use anyhow::Result; use client::Client; -use fs::{FakeFs, Fs, RealFs, RemoveOptions}; +use fs::{repository::GitFileStatus, FakeFs, Fs, RealFs, RemoveOptions}; use git::GITIGNORE; use gpui::{executor::Deterministic, ModelContext, Task, TestAppContext}; use parking_lot::Mutex; @@ -728,9 +728,9 @@ fn randomly_mutate_worktree( } else { other_entry.path.parent().unwrap().into() }; - let mut new_path = new_parent_path.join(gen_name(rng)); + let mut new_path = new_parent_path.join(random_filename(rng)); if new_path.starts_with(&entry.path) { - new_path = gen_name(rng).into(); + new_path = random_filename(rng).into(); } log::info!( @@ -747,7 +747,7 @@ fn randomly_mutate_worktree( } _ => { let task = if entry.is_dir() { - let child_path = entry.path.join(gen_name(rng)); + let child_path = entry.path.join(random_filename(rng)); let is_dir = rng.gen_bool(0.3); log::info!( "creating {} at {:?}", @@ -788,7 +788,7 @@ async fn randomly_mutate_fs( 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)); + let new_path = path.join(random_filename(rng)); if rng.gen() { log::info!( @@ -880,7 +880,7 @@ async fn randomly_mutate_fs( .unwrap(); new_path_parent.to_path_buf() } else { - new_path_parent.join(gen_name(rng)) + new_path_parent.join(random_filename(rng)) }; log::info!( @@ -927,606 +927,597 @@ async fn randomly_mutate_fs( } } -fn gen_name(rng: &mut impl Rng) -> String { +fn random_filename(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}; +#[gpui::test] +async fn test_rename_work_directory(cx: &mut TestAppContext) { + let root = temp_tree(json!({ + "projects": { + "project1": { + "a": "", + "b": "", + } + }, - use super::*; - use collections::HashMap; - use fs::repository::GitFileStatus; - use pretty_assertions::assert_eq; + })); + let root_path = root.path(); - #[gpui::test] - async fn test_rename_work_directory(cx: &mut TestAppContext) { - let root = temp_tree(json!({ - "projects": { - "project1": { - "a": "", - "b": "", - } - }, + 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 root_path = root.path(); + 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(); - 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::>(); - - 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![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, 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": "" - }, - - }), - ) + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) .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), - ], + 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 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)), - ], + 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()) ); - 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)), - ], - ); + let entries = tree.files(false, 0); - 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)], - ) { - let mut entries = expected_statuses - .iter() - .map(|(path, _)| snapshot.entry_for_path(path).unwrap().clone()) - .collect::>(); - snapshot.propagate_git_statuses(&mut entries); - assert_eq!( - entries - .iter() - .map(|e| (e.path.as_ref(), e.git_status)) - .collect::>(), - 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>(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(); + 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()) + }), + ) }) - .skip(offset) - .next() - .expect("Not enough history"); - repo.reset(&new_head.as_object(), git2::ResetType::Soft, None) - .expect("Could not reset"); - } + .collect::>(); - #[allow(dead_code)] - #[track_caller] - fn git_status(repo: &git2::Repository) -> HashMap { - repo.statuses(None) - .unwrap() + 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(|status| (status.path().unwrap().to_string(), status.status())) - .collect() + .map(|e| e.0.clone()) + .collect::>>(), + 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, 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)], + ) { + let mut entries = expected_statuses + .iter() + .map(|(path, _)| snapshot.entry_for_path(path).unwrap().clone()) + .collect::>(); + snapshot.propagate_git_statuses(&mut entries); + assert_eq!( + entries + .iter() + .map(|e| (e.path.as_ref(), e.git_status)) + .collect::>(), + 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>(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) -> collections::HashMap { + repo.statuses(None) + .unwrap() + .iter() + .map(|status| (status.path().unwrap().to_string(), status.status())) + .collect() +}