use crate::{ worktree::{Event, Snapshot, WorktreeModelHandle}, Entry, EntryKind, PathChange, Worktree, }; use anyhow::Result; use client::Client; use fs::{repository::GitFileStatus, FakeFs, Fs, RealFs, RemoveOptions}; use git::GITIGNORE; use gpui::{executor::Deterministic, ModelContext, Task, TestAppContext}; use parking_lot::Mutex; use postage::stream::Stream; use pretty_assertions::assert_eq; use rand::prelude::*; use serde_json::json; use std::{ env, fmt::Write, mem, 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 tree = Worktree::local( build_client(cx), 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 tree = Worktree::local( build_client(cx), 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"),] ); }); // Expand gitignored directory. tree.read_with(cx, |tree, _| { tree.as_local() .unwrap() .refresh_entries_for_paths(vec![Path::new("i/j").into()]) }) .recv() .await; tree.read_with(cx, |tree, _| { 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 tree = Worktree::local( build_client(cx), 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_symlinks_pointing_outside(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", json!({ "dir1": { "deps": { // symlinks here }, "src": { "a.rs": "", "b.rs": "", }, }, "dir2": { "src": { "c.rs": "", "d.rs": "", } }, "dir3": { "deps": {}, "src": { "e.rs": "", "f.rs": "", }, } }), ) .await; // These symlinks point to directories outside of the worktree's root, dir1. fs.insert_symlink("/root/dir1/deps/dep-dir2", "../../dir2".into()) .await; fs.insert_symlink("/root/dir1/deps/dep-dir3", "../../dir3".into()) .await; let tree = Worktree::local( build_client(cx), Path::new("/root/dir1"), true, fs.clone(), Default::default(), &mut cx.to_async(), ) .await .unwrap(); cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) .await; let tree_updates = Arc::new(Mutex::new(Vec::new())); tree.update(cx, |_, cx| { let tree_updates = tree_updates.clone(); cx.subscribe(&tree, move |_, _, event, _| { if let Event::UpdatedEntries(update) = event { tree_updates.lock().extend( update .iter() .map(|(path, _, change)| (path.clone(), *change)), ); } }) .detach(); }); // The symlinked directories are not scanned by default. tree.read_with(cx, |tree, _| { assert_eq!( tree.entries(true) .map(|entry| (entry.path.as_ref(), entry.is_external)) .collect::>(), vec![ (Path::new(""), false), (Path::new("deps"), false), (Path::new("deps/dep-dir2"), true), (Path::new("deps/dep-dir3"), true), (Path::new("src"), false), (Path::new("src/a.rs"), false), (Path::new("src/b.rs"), false), ] ); assert_eq!( tree.entry_for_path("deps/dep-dir2").unwrap().kind, EntryKind::UnloadedDir ); }); // Expand one of the symlinked directories. tree.read_with(cx, |tree, _| { tree.as_local() .unwrap() .refresh_entries_for_paths(vec![Path::new("deps/dep-dir3").into()]) }) .recv() .await; // The expanded directory's contents are loaded. Subdirectories are // not scanned yet. tree.read_with(cx, |tree, _| { assert_eq!( tree.entries(true) .map(|entry| (entry.path.as_ref(), entry.is_external)) .collect::>(), vec![ (Path::new(""), false), (Path::new("deps"), false), (Path::new("deps/dep-dir2"), true), (Path::new("deps/dep-dir3"), true), (Path::new("deps/dep-dir3/deps"), true), (Path::new("deps/dep-dir3/src"), true), (Path::new("src"), false), (Path::new("src/a.rs"), false), (Path::new("src/b.rs"), false), ] ); }); assert_eq!( mem::take(&mut *tree_updates.lock()), &[ (Path::new("deps/dep-dir3").into(), PathChange::Loaded), (Path::new("deps/dep-dir3/deps").into(), PathChange::Loaded), (Path::new("deps/dep-dir3/src").into(), PathChange::Loaded) ] ); // Expand a subdirectory of one of the symlinked directories. tree.read_with(cx, |tree, _| { tree.as_local() .unwrap() .refresh_entries_for_paths(vec![Path::new("deps/dep-dir3/src").into()]) }) .recv() .await; // The expanded subdirectory's contents are loaded. tree.read_with(cx, |tree, _| { assert_eq!( tree.entries(true) .map(|entry| (entry.path.as_ref(), entry.is_external)) .collect::>(), vec![ (Path::new(""), false), (Path::new("deps"), false), (Path::new("deps/dep-dir2"), true), (Path::new("deps/dep-dir3"), true), (Path::new("deps/dep-dir3/deps"), true), (Path::new("deps/dep-dir3/src"), true), (Path::new("deps/dep-dir3/src/e.rs"), true), (Path::new("deps/dep-dir3/src/f.rs"), true), (Path::new("src"), false), (Path::new("src/a.rs"), false), (Path::new("src/b.rs"), false), ] ); }); assert_eq!( mem::take(&mut *tree_updates.lock()), &[ (Path::new("deps/dep-dir3/src").into(), PathChange::Loaded), ( Path::new("deps/dep-dir3/src/e.rs").into(), PathChange::Loaded ), ( Path::new("deps/dep-dir3/src/f.rs").into(), PathChange::Loaded ) ] ); } #[gpui::test] async fn test_open_gitignored_files(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", json!({ ".gitignore": "node_modules\n", "one": { "node_modules": { "a": { "a1.js": "a1", "a2.js": "a2", }, "b": { "b1.js": "b1", "b2.js": "b2", }, "c": { "c1.js": "c1", "c2.js": "c2", } }, }, "two": { "x.js": "", "y.js": "", }, }), ) .await; let tree = Worktree::local( build_client(cx), 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(true) .map(|entry| (entry.path.as_ref(), entry.is_ignored)) .collect::>(), vec![ (Path::new(""), false), (Path::new(".gitignore"), false), (Path::new("one"), false), (Path::new("one/node_modules"), true), (Path::new("two"), false), (Path::new("two/x.js"), false), (Path::new("two/y.js"), false), ] ); }); // Open a file that is nested inside of a gitignored directory that // has not yet been expanded. let prev_read_dir_count = fs.read_dir_call_count(); let buffer = tree .update(cx, |tree, cx| { tree.as_local_mut() .unwrap() .load_buffer(0, "one/node_modules/b/b1.js".as_ref(), cx) }) .await .unwrap(); tree.read_with(cx, |tree, cx| { assert_eq!( tree.entries(true) .map(|entry| (entry.path.as_ref(), entry.is_ignored)) .collect::>(), vec![ (Path::new(""), false), (Path::new(".gitignore"), false), (Path::new("one"), false), (Path::new("one/node_modules"), true), (Path::new("one/node_modules/a"), true), (Path::new("one/node_modules/b"), true), (Path::new("one/node_modules/b/b1.js"), true), (Path::new("one/node_modules/b/b2.js"), true), (Path::new("one/node_modules/c"), true), (Path::new("two"), false), (Path::new("two/x.js"), false), (Path::new("two/y.js"), false), ] ); assert_eq!( buffer.read(cx).file().unwrap().path().as_ref(), Path::new("one/node_modules/b/b1.js") ); // Only the newly-expanded directories are scanned. assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 2); }); // Open another file in a different subdirectory of the same // gitignored directory. let prev_read_dir_count = fs.read_dir_call_count(); let buffer = tree .update(cx, |tree, cx| { tree.as_local_mut() .unwrap() .load_buffer(0, "one/node_modules/a/a2.js".as_ref(), cx) }) .await .unwrap(); tree.read_with(cx, |tree, cx| { assert_eq!( tree.entries(true) .map(|entry| (entry.path.as_ref(), entry.is_ignored)) .collect::>(), vec![ (Path::new(""), false), (Path::new(".gitignore"), false), (Path::new("one"), false), (Path::new("one/node_modules"), true), (Path::new("one/node_modules/a"), true), (Path::new("one/node_modules/a/a1.js"), true), (Path::new("one/node_modules/a/a2.js"), true), (Path::new("one/node_modules/b"), true), (Path::new("one/node_modules/b/b1.js"), true), (Path::new("one/node_modules/b/b2.js"), true), (Path::new("one/node_modules/c"), true), (Path::new("two"), false), (Path::new("two/x.js"), false), (Path::new("two/y.js"), false), ] ); assert_eq!( buffer.read(cx).file().unwrap().path().as_ref(), Path::new("one/node_modules/a/a2.js") ); // Only the newly-expanded directory is scanned. assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 1); }); // No work happens when files and directories change within an unloaded directory. let prev_fs_call_count = fs.read_dir_call_count() + fs.metadata_call_count(); fs.create_dir("/root/one/node_modules/c/lib".as_ref()) .await .unwrap(); cx.foreground().run_until_parked(); assert_eq!( fs.read_dir_call_count() + fs.metadata_call_count() - prev_fs_call_count, 0 ); } #[gpui::test] async fn test_dirs_no_longer_ignored(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", json!({ ".gitignore": "node_modules\n", "a": { "a.js": "", }, "b": { "b.js": "", }, "node_modules": { "c": { "c.js": "", }, "d": { "d.js": "", "e": { "e1.js": "", "e2.js": "", }, "f": { "f1.js": "", "f2.js": "", } }, }, }), ) .await; let tree = Worktree::local( build_client(cx), 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; // Open a file within the gitignored directory, forcing some of its // subdirectories to be read, but not all. let read_dir_count_1 = fs.read_dir_call_count(); tree.read_with(cx, |tree, _| { tree.as_local() .unwrap() .refresh_entries_for_paths(vec![Path::new("node_modules/d/d.js").into()]) }) .recv() .await; // Those subdirectories are now loaded. tree.read_with(cx, |tree, _| { assert_eq!( tree.entries(true) .map(|e| (e.path.as_ref(), e.is_ignored)) .collect::>(), &[ (Path::new(""), false), (Path::new(".gitignore"), false), (Path::new("a"), false), (Path::new("a/a.js"), false), (Path::new("b"), false), (Path::new("b/b.js"), false), (Path::new("node_modules"), true), (Path::new("node_modules/c"), true), (Path::new("node_modules/d"), true), (Path::new("node_modules/d/d.js"), true), (Path::new("node_modules/d/e"), true), (Path::new("node_modules/d/f"), true), ] ); }); let read_dir_count_2 = fs.read_dir_call_count(); assert_eq!(read_dir_count_2 - read_dir_count_1, 2); // Update the gitignore so that node_modules is no longer ignored, // but a subdirectory is ignored fs.save("/root/.gitignore".as_ref(), &"e".into(), Default::default()) .await .unwrap(); cx.foreground().run_until_parked(); // All of the directories that are no longer ignored are now loaded. tree.read_with(cx, |tree, _| { assert_eq!( tree.entries(true) .map(|e| (e.path.as_ref(), e.is_ignored)) .collect::>(), &[ (Path::new(""), false), (Path::new(".gitignore"), false), (Path::new("a"), false), (Path::new("a/a.js"), false), (Path::new("b"), false), (Path::new("b/b.js"), false), // This directory is no longer ignored (Path::new("node_modules"), false), (Path::new("node_modules/c"), false), (Path::new("node_modules/c/c.js"), false), (Path::new("node_modules/d"), false), (Path::new("node_modules/d/d.js"), false), // This subdirectory is now ignored (Path::new("node_modules/d/e"), true), (Path::new("node_modules/d/f"), false), (Path::new("node_modules/d/f/f1.js"), false), (Path::new("node_modules/d/f/f2.js"), false), ] ); }); // Each of the newly-loaded directories is scanned only once. let read_dir_count_3 = fs.read_dir_call_count(); assert_eq!(read_dir_count_3 - read_dir_count_2, 2); } #[gpui::test(iterations = 10)] async fn test_rescan_with_gitignore(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", 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": "" } } }), ) .await; let tree = Worktree::local( build_client(cx), "/root/tree".as_ref(), 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, _| { tree.as_local() .unwrap() .refresh_entries_for_paths(vec![Path::new("ignored-dir").into()]) }) .recv() .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 ); }); fs.create_file( "/root/tree/tracked-dir/tracked-file2".as_ref(), Default::default(), ) .await .unwrap(); fs.create_file( "/root/tree/tracked-dir/ancestor-ignored-file2".as_ref(), Default::default(), ) .await .unwrap(); fs.create_file( "/root/tree/ignored-dir/ignored-file2".as_ref(), Default::default(), ) .await .unwrap(); cx.foreground().run_until_parked(); 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 tree = Worktree::local( build_client(cx), 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 fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", json!({ "b": {}, "c": {}, "d": {}, }), ) .await; let tree = Worktree::local( build_client(cx), "/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] async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { let client_fake = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let fs_fake = FakeFs::new(cx.background()); fs_fake .insert_tree( "/root", json!({ "a": {}, }), ) .await; let tree_fake = Worktree::local( client_fake, "/root".as_ref(), true, fs_fake, Default::default(), &mut cx.to_async(), ) .await .unwrap(); let entry = tree_fake .update(cx, |tree, cx| { tree.as_local_mut() .unwrap() .create_entry("a/b/c/d.txt".as_ref(), false, cx) }) .await .unwrap(); assert!(entry.is_file()); cx.foreground().run_until_parked(); tree_fake.read_with(cx, |tree, _| { assert!(tree.entry_for_path("a/b/c/d.txt").unwrap().is_file()); assert!(tree.entry_for_path("a/b/c/").unwrap().is_dir()); assert!(tree.entry_for_path("a/b/").unwrap().is_dir()); }); let client_real = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let fs_real = Arc::new(RealFs); let temp_root = temp_tree(json!({ "a": {} })); let tree_real = Worktree::local( client_real, temp_root.path(), true, fs_real, Default::default(), &mut cx.to_async(), ) .await .unwrap(); let entry = tree_real .update(cx, |tree, cx| { tree.as_local_mut() .unwrap() .create_entry("a/b/c/d.txt".as_ref(), false, cx) }) .await .unwrap(); assert!(entry.is_file()); cx.foreground().run_until_parked(); tree_real.read_with(cx, |tree, _| { assert!(tree.entry_for_path("a/b/c/d.txt").unwrap().is_file()); assert!(tree.entry_for_path("a/b/c/").unwrap().is_dir()); assert!(tree.entry_for_path("a/b/").unwrap().is_dir()); }); // Test smallest change let entry = tree_real .update(cx, |tree, cx| { tree.as_local_mut() .unwrap() .create_entry("a/b/c/e.txt".as_ref(), false, cx) }) .await .unwrap(); assert!(entry.is_file()); cx.foreground().run_until_parked(); tree_real.read_with(cx, |tree, _| { assert!(tree.entry_for_path("a/b/c/e.txt").unwrap().is_file()); }); // Test largest change let entry = tree_real .update(cx, |tree, cx| { tree.as_local_mut() .unwrap() .create_entry("d/e/f/g.txt".as_ref(), false, cx) }) .await .unwrap(); assert!(entry.is_file()); cx.foreground().run_until_parked(); tree_real.read_with(cx, |tree, _| { assert!(tree.entry_for_path("d/e/f/g.txt").unwrap().is_file()); assert!(tree.entry_for_path("d/e/f").unwrap().is_dir()); assert!(tree.entry_for_path("d/e/").unwrap().is_dir()); assert!(tree.entry_for_path("d/").unwrap().is_dir()); }); } #[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 worktree = Worktree::local( build_client(cx), 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(true) }); 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(true); 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 worktree = Worktree::local( build_client(cx), 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(true); let expanded_paths = snapshot .expanded_entries() .map(|e| e.path.clone()) .collect::>(); { let new_worktree = Worktree::local( build_client(cx), 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; new_worktree .update(cx, |tree, _| { tree.as_local_mut() .unwrap() .refresh_entries_for_paths(expanded_paths) }) .recv() .await; let new_snapshot = new_worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()); assert_eq!( snapshot.entries_without_ids(true), new_snapshot.entries_without_ids(true) ); } 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) .map(ignore_pending_dir) .collect::>(), snapshot .entries(true) .map(ignore_pending_dir) .collect::>(), "wrong updates after snapshot {i}: {updates:#?}", ); } fn ignore_pending_dir(entry: &Entry) -> Entry { let mut entry = entry.clone(); if entry.kind.is_dir() { entry.kind = EntryKind::Dir } entry } } // The worktree's `UpdatedEntries` event can be used to follow along with // all changes to the worktree's snapshot. fn check_worktree_change_events(tree: &mut Worktree, cx: &mut 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::Added => entries.insert(ix, entry.unwrap()), PathChange::Removed => drop(entries.remove(ix)), PathChange::Updated => { let entry = entry.unwrap(); let existing_entry = entries.get_mut(ix).unwrap(); assert_eq!(existing_entry.path, entry.path); *existing_entry = entry; } PathChange::AddedOrUpdated | PathChange::Loaded => { let entry = entry.unwrap(); if entries.get(ix).map(|e| &e.path) == Some(&entry.path) { *entries.get_mut(ix).unwrap() = entry; } else { entries.insert(ix, entry); } } } } let new_entries = tree.entries(true).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(random_filename(rng)); if new_path.starts_with(&entry.path) { new_path = random_filename(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(random_filename(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(random_filename(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(random_filename(rng)) }; log::info!( "renaming {:?} to {}{:?}", old_path.strip_prefix(&root_path).unwrap(), if overwrite_existing_dir { "overwrite " } else { "" }, new_path.strip_prefix(&root_path).unwrap() ); fs.rename( &old_path, &new_path, fs::RenameOptions { overwrite: true, ignore_if_exists: true, }, ) .await .unwrap(); } else if fs.is_file(&old_path).await { log::info!( "deleting file {:?}", old_path.strip_prefix(&root_path).unwrap() ); fs.remove_file(old_path, Default::default()).await.unwrap(); } else { log::info!( "deleting dir {:?}", old_path.strip_prefix(&root_path).unwrap() ); fs.remove_dir( &old_path, RemoveOptions { recursive: true, ignore_if_not_exists: true, }, ) .await .unwrap(); } } } fn random_filename(rng: &mut impl Rng) -> String { (0..6) .map(|_| rng.sample(rand::distributions::Alphanumeric)) .map(char::from) .collect() } #[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 tree = Worktree::local( build_client(cx), 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 tree = Worktree::local( build_client(cx), 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 }, })); 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::new("project"); // Set up git repository before creating the worktree. 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(A_TXT, &repo); git_add(E_TXT, &repo); git_add(DOTGITIGNORE, &repo); git_commit("Initial commit", &repo); let tree = Worktree::local( build_client(cx), root.path(), true, Arc::new(RealFs), Default::default(), &mut cx.to_async(), ) .await .unwrap(); tree.flush_fs_events(cx).await; cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) .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) ); }); // Modify a file in the working copy. std::fs::write(work_dir.join(A_TXT), "aa").unwrap(); tree.flush_fs_events(cx).await; deterministic.run_until_parked(); // The worktree detects that the file's git status has changed. tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); assert_eq!( snapshot.status_for_file(project_path.join(A_TXT)), Some(GitFileStatus::Modified) ); }); // Create a commit in the git repository. git_add(A_TXT, &repo); git_add(B_TXT, &repo); git_commit("Committing modified and added", &repo); tree.flush_fs_events(cx).await; deterministic.run_until_parked(); // The worktree detects that the files' git status have changed. 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); }); // Modify files in the working copy and perform git operations on other files. 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 tree = Worktree::local( build_client(cx), 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 ); } } fn build_client(cx: &mut TestAppContext) -> Arc { let http_client = FakeHttpClient::with_404_response(); cx.read(|cx| Client::new(http_client, cx)) } #[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() }