From 0824bc570001711bc5a0fdc639227d5f7d553163 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 7 Jun 2023 17:11:43 -0700 Subject: [PATCH] Switch to attaching git statuses to their associated entries (#2571) This rewrites and simplifies the git status system by attaching the git status to each individual entry. This also improves the git testing infrastructure to cover more cases and be more accurate to how file events actually occur. This also fixes several other bugs in the worktree and the buffer, and stops any randomly generated actions from happening inside a `.git` folder. Hopefully, we can undo this change soon once our randomized testing is more robust. Release Notes: - Will require a DB migration TODO: - [x] Pass randomized tests - [x] Get ready for merging --- Cargo.lock | 1 + crates/clock/src/clock.rs | 1 + .../20221109000000_test_schema.sql | 17 +- ...30605191135_remove_repository_statuses.sql | 4 + crates/collab/src/db.rs | 143 +--- crates/collab/src/db/worktree_entry.rs | 1 + crates/collab/src/tests/integration_tests.rs | 80 +- .../src/tests/randomized_integration_tests.rs | 64 +- crates/fs/Cargo.toml | 2 + crates/fs/src/fs.rs | 71 +- crates/fs/src/repository.rs | 68 +- crates/project/src/project.rs | 33 +- crates/project/src/worktree.rs | 789 +++++++++++------- crates/project_panel/src/project_panel.rs | 14 +- crates/rpc/proto/zed.proto | 3 +- crates/sum_tree/src/sum_tree.rs | 5 + crates/util/src/util.rs | 2 +- 17 files changed, 718 insertions(+), 580 deletions(-) create mode 100644 crates/collab/migrations/20230605191135_remove_repository_statuses.sql diff --git a/Cargo.lock b/Cargo.lock index 807ed777b2c19fdf1b5bf1af887a5dc5296f268c..5fb521a81b456b0880cb0111d67aafb2dd7b7013 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2440,6 +2440,7 @@ dependencies = [ "parking_lot 0.11.2", "regex", "rope", + "rpc", "serde", "serde_derive", "serde_json", diff --git a/crates/clock/src/clock.rs b/crates/clock/src/clock.rs index 89f8fda3ee2cbe96d47614b24f1e4ba079ac7970..bc936fcb9974ad84a5a221f5cec78e239e78cb29 100644 --- a/crates/clock/src/clock.rs +++ b/crates/clock/src/clock.rs @@ -66,6 +66,7 @@ impl<'a> AddAssign<&'a Local> for Local { } } +/// A vector clock #[derive(Clone, Default, Hash, Eq, PartialEq)] pub struct Global(SmallVec<[u32; 8]>); diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index b0b2a684d9501bf135f4280022437ef1c39f1230..595d841d075773699d88e4556354f4ef43b2b410 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -76,6 +76,7 @@ CREATE TABLE "worktree_entries" ( "is_symlink" BOOL NOT NULL, "is_ignored" BOOL NOT NULL, "is_deleted" BOOL NOT NULL, + "git_status" INTEGER, PRIMARY KEY(project_id, worktree_id, id), FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE ); @@ -96,22 +97,6 @@ CREATE TABLE "worktree_repositories" ( CREATE INDEX "index_worktree_repositories_on_project_id" ON "worktree_repositories" ("project_id"); CREATE INDEX "index_worktree_repositories_on_project_id_and_worktree_id" ON "worktree_repositories" ("project_id", "worktree_id"); -CREATE TABLE "worktree_repository_statuses" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INTEGER NOT NULL, - "work_directory_id" INTEGER NOT NULL, - "repo_path" VARCHAR NOT NULL, - "status" INTEGER NOT NULL, - "scan_id" INTEGER NOT NULL, - "is_deleted" BOOL NOT NULL, - PRIMARY KEY(project_id, worktree_id, work_directory_id, repo_path), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE, - FOREIGN KEY(project_id, worktree_id, work_directory_id) REFERENCES worktree_entries (project_id, worktree_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_worktree_repository_statuses_on_project_id" ON "worktree_repository_statuses" ("project_id"); -CREATE INDEX "index_worktree_repository_statuses_on_project_id_and_worktree_id" ON "worktree_repository_statuses" ("project_id", "worktree_id"); -CREATE INDEX "index_worktree_repository_statuses_on_project_id_and_worktree_id_and_work_directory_id" ON "worktree_repository_statuses" ("project_id", "worktree_id", "work_directory_id"); - CREATE TABLE "worktree_settings_files" ( "project_id" INTEGER NOT NULL, "worktree_id" INTEGER NOT NULL, diff --git a/crates/collab/migrations/20230605191135_remove_repository_statuses.sql b/crates/collab/migrations/20230605191135_remove_repository_statuses.sql new file mode 100644 index 0000000000000000000000000000000000000000..02ebc488704f099410d7f70dcae055c90862ec4f --- /dev/null +++ b/crates/collab/migrations/20230605191135_remove_repository_statuses.sql @@ -0,0 +1,4 @@ +DROP TABLE "worktree_repository_statuses"; + +ALTER TABLE "worktree_entries" +ADD "git_status" INT8; diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 1deca1baa8036113c4ad2c4f0535a18085cb5db1..7e2c376bc2c9eef6b030fdc7b2b4017d157f9216 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1539,6 +1539,7 @@ impl Database { }), is_symlink: db_entry.is_symlink, is_ignored: db_entry.is_ignored, + git_status: db_entry.git_status.map(|status| status as i32), }); } } @@ -1573,54 +1574,6 @@ impl Database { worktree.updated_repositories.push(proto::RepositoryEntry { work_directory_id: db_repository.work_directory_id as u64, branch: db_repository.branch, - removed_repo_paths: Default::default(), - updated_statuses: Default::default(), - }); - } - } - } - - // Repository Status Entries - for repository in worktree.updated_repositories.iter_mut() { - let repository_status_entry_filter = - if let Some(rejoined_worktree) = rejoined_worktree { - worktree_repository_statuses::Column::ScanId - .gt(rejoined_worktree.scan_id) - } else { - worktree_repository_statuses::Column::IsDeleted.eq(false) - }; - - let mut db_repository_statuses = - worktree_repository_statuses::Entity::find() - .filter( - Condition::all() - .add( - worktree_repository_statuses::Column::ProjectId - .eq(project.id), - ) - .add( - worktree_repository_statuses::Column::WorktreeId - .eq(worktree.id), - ) - .add( - worktree_repository_statuses::Column::WorkDirectoryId - .eq(repository.work_directory_id), - ) - .add(repository_status_entry_filter), - ) - .stream(&*tx) - .await?; - - while let Some(db_status_entry) = db_repository_statuses.next().await { - let db_status_entry = db_status_entry?; - if db_status_entry.is_deleted { - repository - .removed_repo_paths - .push(db_status_entry.repo_path); - } else { - repository.updated_statuses.push(proto::StatusEntry { - repo_path: db_status_entry.repo_path, - status: db_status_entry.status as i32, }); } } @@ -2396,6 +2349,7 @@ impl Database { mtime_nanos: ActiveValue::set(mtime.nanos as i32), is_symlink: ActiveValue::set(entry.is_symlink), is_ignored: ActiveValue::set(entry.is_ignored), + git_status: ActiveValue::set(entry.git_status.map(|status| status as i64)), is_deleted: ActiveValue::set(false), scan_id: ActiveValue::set(update.scan_id as i64), } @@ -2414,6 +2368,7 @@ impl Database { worktree_entry::Column::MtimeNanos, worktree_entry::Column::IsSymlink, worktree_entry::Column::IsIgnored, + worktree_entry::Column::GitStatus, worktree_entry::Column::ScanId, ]) .to_owned(), @@ -2467,68 +2422,6 @@ impl Database { ) .exec(&*tx) .await?; - - for repository in update.updated_repositories.iter() { - if !repository.updated_statuses.is_empty() { - worktree_repository_statuses::Entity::insert_many( - repository.updated_statuses.iter().map(|status_entry| { - worktree_repository_statuses::ActiveModel { - project_id: ActiveValue::set(project_id), - worktree_id: ActiveValue::set(worktree_id), - work_directory_id: ActiveValue::set( - repository.work_directory_id as i64, - ), - repo_path: ActiveValue::set(status_entry.repo_path.clone()), - status: ActiveValue::set(status_entry.status as i64), - scan_id: ActiveValue::set(update.scan_id as i64), - is_deleted: ActiveValue::set(false), - } - }), - ) - .on_conflict( - OnConflict::columns([ - worktree_repository_statuses::Column::ProjectId, - worktree_repository_statuses::Column::WorktreeId, - worktree_repository_statuses::Column::WorkDirectoryId, - worktree_repository_statuses::Column::RepoPath, - ]) - .update_columns([ - worktree_repository_statuses::Column::ScanId, - worktree_repository_statuses::Column::Status, - worktree_repository_statuses::Column::IsDeleted, - ]) - .to_owned(), - ) - .exec(&*tx) - .await?; - } - - if !repository.removed_repo_paths.is_empty() { - worktree_repository_statuses::Entity::update_many() - .filter( - worktree_repository_statuses::Column::ProjectId - .eq(project_id) - .and( - worktree_repository_statuses::Column::WorktreeId - .eq(worktree_id), - ) - .and( - worktree_repository_statuses::Column::WorkDirectoryId - .eq(repository.work_directory_id as i64), - ) - .and(worktree_repository_statuses::Column::RepoPath.is_in( - repository.removed_repo_paths.iter().map(String::as_str), - )), - ) - .set(worktree_repository_statuses::ActiveModel { - is_deleted: ActiveValue::Set(true), - scan_id: ActiveValue::Set(update.scan_id as i64), - ..Default::default() - }) - .exec(&*tx) - .await?; - } - } } if !update.removed_repositories.is_empty() { @@ -2812,6 +2705,7 @@ impl Database { }), is_symlink: db_entry.is_symlink, is_ignored: db_entry.is_ignored, + git_status: db_entry.git_status.map(|status| status as i32), }); } } @@ -2837,41 +2731,12 @@ impl Database { proto::RepositoryEntry { work_directory_id: db_repository_entry.work_directory_id as u64, branch: db_repository_entry.branch, - removed_repo_paths: Default::default(), - updated_statuses: Default::default(), }, ); } } } - { - let mut db_status_entries = worktree_repository_statuses::Entity::find() - .filter( - Condition::all() - .add(worktree_repository_statuses::Column::ProjectId.eq(project_id)) - .add(worktree_repository_statuses::Column::IsDeleted.eq(false)), - ) - .stream(&*tx) - .await?; - - while let Some(db_status_entry) = db_status_entries.next().await { - let db_status_entry = db_status_entry?; - if let Some(worktree) = worktrees.get_mut(&(db_status_entry.worktree_id as u64)) - { - if let Some(repository_entry) = worktree - .repository_entries - .get_mut(&(db_status_entry.work_directory_id as u64)) - { - repository_entry.updated_statuses.push(proto::StatusEntry { - repo_path: db_status_entry.repo_path, - status: db_status_entry.status as i32, - }); - } - } - } - } - // Populate worktree diagnostic summaries. { let mut db_summaries = worktree_diagnostic_summary::Entity::find() diff --git a/crates/collab/src/db/worktree_entry.rs b/crates/collab/src/db/worktree_entry.rs index 4eb1648b814991f296f30ddfcbc4eca809d74af7..f2df808ee3ca7d4792d649896b23c867e9fd444b 100644 --- a/crates/collab/src/db/worktree_entry.rs +++ b/crates/collab/src/db/worktree_entry.rs @@ -15,6 +15,7 @@ pub struct Model { pub inode: i64, pub mtime_seconds: i64, pub mtime_nanos: i32, + pub git_status: Option, pub is_symlink: bool, pub is_ignored: bool, pub is_deleted: bool, diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 5bc1a4e414d393a346af706b961f59236eb656bc..ce4ede8684831fd78d0d6a16cc49905581fadc1a 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -2415,14 +2415,10 @@ async fn test_git_diff_base_change( " .unindent(); - client_a - .fs - .as_fake() - .set_index_for_repo( - Path::new("/dir/.git"), - &[(Path::new("a.txt"), diff_base.clone())], - ) - .await; + client_a.fs.as_fake().set_index_for_repo( + Path::new("/dir/.git"), + &[(Path::new("a.txt"), diff_base.clone())], + ); // Create the buffer let buffer_local_a = project_local @@ -2464,14 +2460,10 @@ async fn test_git_diff_base_change( ); }); - client_a - .fs - .as_fake() - .set_index_for_repo( - Path::new("/dir/.git"), - &[(Path::new("a.txt"), new_diff_base.clone())], - ) - .await; + client_a.fs.as_fake().set_index_for_repo( + Path::new("/dir/.git"), + &[(Path::new("a.txt"), new_diff_base.clone())], + ); // Wait for buffer_local_a to receive it deterministic.run_until_parked(); @@ -2513,14 +2505,10 @@ async fn test_git_diff_base_change( " .unindent(); - client_a - .fs - .as_fake() - .set_index_for_repo( - Path::new("/dir/sub/.git"), - &[(Path::new("b.txt"), diff_base.clone())], - ) - .await; + client_a.fs.as_fake().set_index_for_repo( + Path::new("/dir/sub/.git"), + &[(Path::new("b.txt"), diff_base.clone())], + ); // Create the buffer let buffer_local_b = project_local @@ -2562,14 +2550,10 @@ async fn test_git_diff_base_change( ); }); - client_a - .fs - .as_fake() - .set_index_for_repo( - Path::new("/dir/sub/.git"), - &[(Path::new("b.txt"), new_diff_base.clone())], - ) - .await; + client_a.fs.as_fake().set_index_for_repo( + Path::new("/dir/sub/.git"), + &[(Path::new("b.txt"), new_diff_base.clone())], + ); // Wait for buffer_local_b to receive it deterministic.run_until_parked(); @@ -2646,8 +2630,7 @@ async fn test_git_branch_name( client_a .fs .as_fake() - .set_branch_name(Path::new("/dir/.git"), Some("branch-1")) - .await; + .set_branch_name(Path::new("/dir/.git"), Some("branch-1")); // Wait for it to catch up to the new branch deterministic.run_until_parked(); @@ -2673,8 +2656,7 @@ async fn test_git_branch_name( client_a .fs .as_fake() - .set_branch_name(Path::new("/dir/.git"), Some("branch-2")) - .await; + .set_branch_name(Path::new("/dir/.git"), Some("branch-2")); // Wait for buffer_local_a to receive it deterministic.run_until_parked(); @@ -2726,17 +2708,13 @@ async fn test_git_status_sync( const A_TXT: &'static str = "a.txt"; const B_TXT: &'static str = "b.txt"; - client_a - .fs - .as_fake() - .set_status_for_repo( - Path::new("/dir/.git"), - &[ - (&Path::new(A_TXT), GitFileStatus::Added), - (&Path::new(B_TXT), GitFileStatus::Added), - ], - ) - .await; + client_a.fs.as_fake().set_status_for_repo_via_git_operation( + Path::new("/dir/.git"), + &[ + (&Path::new(A_TXT), GitFileStatus::Added), + (&Path::new(B_TXT), GitFileStatus::Added), + ], + ); let (project_local, _worktree_id) = client_a.build_local_project("/dir", cx_a).await; let project_id = active_call_a @@ -2763,8 +2741,7 @@ async fn test_git_status_sync( assert_eq!(worktrees.len(), 1); let worktree = worktrees[0].clone(); let snapshot = worktree.read(cx).snapshot(); - let root_entry = snapshot.root_git_entry().unwrap(); - assert_eq!(root_entry.status_for_file(&snapshot, file), status); + assert_eq!(snapshot.status_for_file(file), status); } // Smoke test status reading @@ -2780,14 +2757,13 @@ async fn test_git_status_sync( client_a .fs .as_fake() - .set_status_for_repo( + .set_status_for_repo_via_working_copy_change( Path::new("/dir/.git"), &[ (&Path::new(A_TXT), GitFileStatus::Modified), (&Path::new(B_TXT), GitFileStatus::Modified), ], - ) - .await; + ); // Wait for buffer_local_a to receive it deterministic.run_until_parked(); diff --git a/crates/collab/src/tests/randomized_integration_tests.rs b/crates/collab/src/tests/randomized_integration_tests.rs index 3beff6942aed3b1e3c001bcc9190b206663dbc3b..a95938f6b8dc3504256099a282462a249d695635 100644 --- a/crates/collab/src/tests/randomized_integration_tests.rs +++ b/crates/collab/src/tests/randomized_integration_tests.rs @@ -422,7 +422,7 @@ async fn apply_client_operation( ); ensure_project_shared(&project, client, cx).await; - if !client.fs.paths().contains(&new_root_path) { + if !client.fs.paths(false).contains(&new_root_path) { client.fs.create_dir(&new_root_path).await.unwrap(); } project @@ -628,12 +628,13 @@ async fn apply_client_operation( ensure_project_shared(&project, client, cx).await; let requested_version = buffer.read_with(cx, |buffer, _| buffer.version()); - let save = project.update(cx, |project, cx| project.save_buffer(buffer, cx)); - let save = cx.background().spawn(async move { - let (saved_version, _, _) = save - .await + let save = project.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)); + let save = cx.spawn(|cx| async move { + save.await .map_err(|err| anyhow!("save request failed: {:?}", err))?; - assert!(saved_version.observed_all(&requested_version)); + assert!(buffer + .read_with(&cx, |buffer, _| { buffer.saved_version().to_owned() }) + .observed_all(&requested_version)); anyhow::Ok(()) }); if detach { @@ -743,7 +744,7 @@ async fn apply_client_operation( } => { if !client .fs - .directories() + .directories(false) .contains(&path.parent().unwrap().to_owned()) { return Err(TestError::Inapplicable); @@ -770,10 +771,16 @@ async fn apply_client_operation( repo_path, contents, } => { - if !client.fs.directories().contains(&repo_path) { + if !client.fs.directories(false).contains(&repo_path) { return Err(TestError::Inapplicable); } + for (path, _) in contents.iter() { + if !client.fs.files().contains(&repo_path.join(path)) { + return Err(TestError::Inapplicable); + } + } + log::info!( "{}: writing git index for repo {:?}: {:?}", client.username, @@ -789,13 +796,13 @@ async fn apply_client_operation( if client.fs.metadata(&dot_git_dir).await?.is_none() { client.fs.create_dir(&dot_git_dir).await?; } - client.fs.set_index_for_repo(&dot_git_dir, &contents).await; + client.fs.set_index_for_repo(&dot_git_dir, &contents); } GitOperation::WriteGitBranch { repo_path, new_branch, } => { - if !client.fs.directories().contains(&repo_path) { + if !client.fs.directories(false).contains(&repo_path) { return Err(TestError::Inapplicable); } @@ -810,15 +817,21 @@ async fn apply_client_operation( if client.fs.metadata(&dot_git_dir).await?.is_none() { client.fs.create_dir(&dot_git_dir).await?; } - client.fs.set_branch_name(&dot_git_dir, new_branch).await; + client.fs.set_branch_name(&dot_git_dir, new_branch); } GitOperation::WriteGitStatuses { repo_path, statuses, + git_operation, } => { - if !client.fs.directories().contains(&repo_path) { + if !client.fs.directories(false).contains(&repo_path) { return Err(TestError::Inapplicable); } + for (path, _) in statuses.iter() { + if !client.fs.files().contains(&repo_path.join(path)) { + return Err(TestError::Inapplicable); + } + } log::info!( "{}: writing git statuses for repo {:?}: {:?}", @@ -838,10 +851,16 @@ async fn apply_client_operation( client.fs.create_dir(&dot_git_dir).await?; } - client - .fs - .set_status_for_repo(&dot_git_dir, statuses.as_slice()) - .await; + if git_operation { + client + .fs + .set_status_for_repo_via_git_operation(&dot_git_dir, statuses.as_slice()); + } else { + client.fs.set_status_for_repo_via_working_copy_change( + &dot_git_dir, + statuses.as_slice(), + ); + } } }, } @@ -913,9 +932,10 @@ fn check_consistency_between_clients(clients: &[(Rc, TestAppContext) assert_eq!( guest_snapshot.entries(false).collect::>(), host_snapshot.entries(false).collect::>(), - "{} has different snapshot than the host for worktree {:?} and project {:?}", + "{} has different snapshot than the host for worktree {:?} ({:?}) and project {:?}", client.username, host_snapshot.abs_path(), + id, guest_project.remote_id(), ); assert_eq!(guest_snapshot.repositories().collect::>(), host_snapshot.repositories().collect::>(), @@ -1230,6 +1250,7 @@ enum GitOperation { WriteGitStatuses { repo_path: PathBuf, statuses: Vec<(PathBuf, GitFileStatus)>, + git_operation: bool, }, } @@ -1575,7 +1596,7 @@ impl TestPlan { .choose(&mut self.rng) .cloned() else { continue }; let project_root_name = root_name_for_project(&project, cx); - let mut paths = client.fs.paths(); + let mut paths = client.fs.paths(false); paths.remove(0); let new_root_path = if paths.is_empty() || self.rng.gen() { Path::new("/").join(&self.next_root_dir_name(user_id)) @@ -1755,7 +1776,7 @@ impl TestPlan { let is_dir = self.rng.gen::(); let content; let mut path; - let dir_paths = client.fs.directories(); + let dir_paths = client.fs.directories(false); if is_dir { content = String::new(); @@ -1809,7 +1830,7 @@ impl TestPlan { let repo_path = client .fs - .directories() + .directories(false) .choose(&mut self.rng) .unwrap() .clone(); @@ -1855,9 +1876,12 @@ impl TestPlan { }) .collect::>(); + let git_operation = self.rng.gen::(); + GitOperation::WriteGitStatuses { repo_path, statuses, + git_operation, } } _ => unreachable!(), diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 54c6ce362a96afd1a1b3a3178eab4936536536af..7dda3f7273ee8513b51119513d6d7e2a3b9bf4f9 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -14,6 +14,8 @@ lsp = { path = "../lsp" } rope = { path = "../rope" } util = { path = "../util" } sum_tree = { path = "../sum_tree" } +rpc = { path = "../rpc" } + anyhow.workspace = true async-trait.workspace = true futures.workspace = true diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 4c6b6c24d300273b1cbe3f2386cc51ba19cdc5d5..fee7765d497c865903bebe10cdb4a776e565559a 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -29,6 +29,8 @@ use collections::{btree_map, BTreeMap}; #[cfg(any(test, feature = "test-support"))] use repository::{FakeGitRepositoryState, GitFileStatus}; #[cfg(any(test, feature = "test-support"))] +use std::ffi::OsStr; +#[cfg(any(test, feature = "test-support"))] use std::sync::Weak; lazy_static! { @@ -501,6 +503,11 @@ impl FakeFsState { } } +#[cfg(any(test, feature = "test-support"))] +lazy_static! { + pub static ref FS_DOT_GIT: &'static OsStr = OsStr::new(".git"); +} + #[cfg(any(test, feature = "test-support"))] impl FakeFs { pub fn new(executor: Arc) -> Arc { @@ -619,7 +626,7 @@ impl FakeFs { .boxed() } - pub fn with_git_state(&self, dot_git: &Path, f: F) + pub fn with_git_state(&self, dot_git: &Path, emit_git_event: bool, f: F) where F: FnOnce(&mut FakeGitRepositoryState), { @@ -633,18 +640,22 @@ impl FakeFs { f(&mut repo_state); - state.emit_event([dot_git]); + if emit_git_event { + state.emit_event([dot_git]); + } } else { panic!("not a directory"); } } - pub async fn set_branch_name(&self, dot_git: &Path, branch: Option>) { - self.with_git_state(dot_git, |state| state.branch_name = branch.map(Into::into)) + pub fn set_branch_name(&self, dot_git: &Path, branch: Option>) { + self.with_git_state(dot_git, true, |state| { + state.branch_name = branch.map(Into::into) + }) } - pub async fn set_index_for_repo(&self, dot_git: &Path, head_state: &[(&Path, String)]) { - self.with_git_state(dot_git, |state| { + pub fn set_index_for_repo(&self, dot_git: &Path, head_state: &[(&Path, String)]) { + self.with_git_state(dot_git, true, |state| { state.index_contents.clear(); state.index_contents.extend( head_state @@ -654,8 +665,32 @@ impl FakeFs { }); } - pub async fn set_status_for_repo(&self, dot_git: &Path, statuses: &[(&Path, GitFileStatus)]) { - self.with_git_state(dot_git, |state| { + pub fn set_status_for_repo_via_working_copy_change( + &self, + dot_git: &Path, + statuses: &[(&Path, GitFileStatus)], + ) { + self.with_git_state(dot_git, false, |state| { + state.worktree_statuses.clear(); + state.worktree_statuses.extend( + statuses + .iter() + .map(|(path, content)| ((**path).into(), content.clone())), + ); + }); + self.state.lock().emit_event( + statuses + .iter() + .map(|(path, _)| dot_git.parent().unwrap().join(path)), + ); + } + + pub fn set_status_for_repo_via_git_operation( + &self, + dot_git: &Path, + statuses: &[(&Path, GitFileStatus)], + ) { + self.with_git_state(dot_git, true, |state| { state.worktree_statuses.clear(); state.worktree_statuses.extend( statuses @@ -665,7 +700,7 @@ impl FakeFs { }); } - pub fn paths(&self) -> Vec { + pub fn paths(&self, include_dot_git: bool) -> Vec { let mut result = Vec::new(); let mut queue = collections::VecDeque::new(); queue.push_back((PathBuf::from("/"), self.state.lock().root.clone())); @@ -675,12 +710,18 @@ impl FakeFs { queue.push_back((path.join(name), entry.clone())); } } - result.push(path); + if include_dot_git + || !path + .components() + .any(|component| component.as_os_str() == *FS_DOT_GIT) + { + result.push(path); + } } result } - pub fn directories(&self) -> Vec { + pub fn directories(&self, include_dot_git: bool) -> Vec { let mut result = Vec::new(); let mut queue = collections::VecDeque::new(); queue.push_back((PathBuf::from("/"), self.state.lock().root.clone())); @@ -689,7 +730,13 @@ impl FakeFs { for (name, entry) in entries { queue.push_back((path.join(name), entry.clone())); } - result.push(path); + if include_dot_git + || !path + .components() + .any(|component| component.as_os_str() == *FS_DOT_GIT) + { + result.push(path); + } } } result diff --git a/crates/fs/src/repository.rs b/crates/fs/src/repository.rs index 2c309351fc004f71e451fb866d64293168627c4f..488262887fd6c5c47ceee129840d72a5201531ca 100644 --- a/crates/fs/src/repository.rs +++ b/crates/fs/src/repository.rs @@ -1,6 +1,8 @@ use anyhow::Result; use collections::HashMap; +use git2::ErrorCode; use parking_lot::Mutex; +use rpc::proto; use serde_derive::{Deserialize, Serialize}; use std::{ cmp::Ordering, @@ -24,7 +26,7 @@ pub trait GitRepository: Send { fn statuses(&self) -> Option>; - fn status(&self, path: &RepoPath) -> Option; + fn status(&self, path: &RepoPath) -> Result>; } impl std::fmt::Debug for dyn GitRepository { @@ -91,9 +93,18 @@ impl GitRepository for LibGitRepository { Some(map) } - fn status(&self, path: &RepoPath) -> Option { - let status = self.status_file(path).log_err()?; - read_status(status) + fn status(&self, path: &RepoPath) -> Result> { + let status = self.status_file(path); + match status { + Ok(status) => Ok(read_status(status)), + Err(e) => { + if e.code() == ErrorCode::NotFound { + Ok(None) + } else { + Err(e.into()) + } + } + } } } @@ -155,9 +166,9 @@ impl GitRepository for FakeGitRepository { Some(map) } - fn status(&self, path: &RepoPath) -> Option { + fn status(&self, path: &RepoPath) -> Result> { let state = self.state.lock(); - state.worktree_statuses.get(path).cloned() + Ok(state.worktree_statuses.get(path).cloned()) } } @@ -197,8 +208,51 @@ pub enum GitFileStatus { Conflict, } +impl GitFileStatus { + pub fn merge( + this: Option, + other: Option, + prefer_other: bool, + ) -> Option { + if prefer_other { + return other; + } else { + match (this, other) { + (Some(GitFileStatus::Conflict), _) | (_, Some(GitFileStatus::Conflict)) => { + Some(GitFileStatus::Conflict) + } + (Some(GitFileStatus::Modified), _) | (_, Some(GitFileStatus::Modified)) => { + Some(GitFileStatus::Modified) + } + (Some(GitFileStatus::Added), _) | (_, Some(GitFileStatus::Added)) => { + Some(GitFileStatus::Added) + } + _ => None, + } + } + } + + pub fn from_proto(git_status: Option) -> Option { + git_status.and_then(|status| { + proto::GitStatus::from_i32(status).map(|status| match status { + proto::GitStatus::Added => GitFileStatus::Added, + proto::GitStatus::Modified => GitFileStatus::Modified, + proto::GitStatus::Conflict => GitFileStatus::Conflict, + }) + }) + } + + pub fn to_proto(self) -> i32 { + match self { + GitFileStatus::Added => proto::GitStatus::Added as i32, + GitFileStatus::Modified => proto::GitStatus::Modified as i32, + GitFileStatus::Conflict => proto::GitStatus::Conflict as i32, + } + } +} + #[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)] -pub struct RepoPath(PathBuf); +pub struct RepoPath(pub PathBuf); impl RepoPath { pub fn new(path: PathBuf) -> Self { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 39d8ea85129b0bdd282e4fac243929457c9b0e57..24666ce14b13d7b3d74ddb9c1ad9c8cae27473aa 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -37,8 +37,8 @@ use language::{ range_from_lsp, range_to_lsp, Anchor, Bias, Buffer, CachedLspAdapter, CodeAction, CodeLabel, Completion, Diagnostic, DiagnosticEntry, DiagnosticSet, Diff, Event as BufferEvent, File as _, Language, LanguageRegistry, LanguageServerName, LocalFile, OffsetRangeExt, Operation, Patch, - PendingLanguageServer, PointUtf16, RopeFingerprint, TextBufferSnapshot, ToOffset, ToPointUtf16, - Transaction, Unclipped, + PendingLanguageServer, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, + Unclipped, }; use log::error; use lsp::{ @@ -69,7 +69,7 @@ use std::{ atomic::{AtomicUsize, Ordering::SeqCst}, Arc, }, - time::{Duration, Instant, SystemTime}, + time::{Duration, Instant}, }; use terminals::Terminals; use util::{ @@ -1616,7 +1616,7 @@ impl Project { &self, buffer: ModelHandle, cx: &mut ModelContext, - ) -> Task> { + ) -> Task> { let Some(file) = File::from_dyn(buffer.read(cx).file()) else { return Task::ready(Err(anyhow!("buffer doesn't have a file"))); }; @@ -5156,9 +5156,9 @@ impl Project { return None; } let path = &project_path.path; - changed_repos.iter().find(|(work_dir, change)| { - path.starts_with(work_dir) && change.git_dir_changed - })?; + changed_repos + .iter() + .find(|(work_dir, _)| path.starts_with(work_dir))?; let receiver = receiver.clone(); let path = path.clone(); Some(async move { @@ -5181,9 +5181,9 @@ impl Project { return None; } let path = file.path(); - changed_repos.iter().find(|(work_dir, change)| { - path.starts_with(work_dir) && change.git_dir_changed - })?; + changed_repos + .iter() + .find(|(work_dir, _)| path.starts_with(work_dir))?; Some((buffer, path.clone())) }) .collect::>(); @@ -5984,16 +5984,15 @@ impl Project { .await?; let buffer_id = buffer.read_with(&cx, |buffer, _| buffer.remote_id()); - let (saved_version, fingerprint, mtime) = this - .update(&mut cx, |this, cx| this.save_buffer(buffer, cx)) + this.update(&mut cx, |this, cx| this.save_buffer(buffer.clone(), cx)) .await?; - Ok(proto::BufferSaved { + Ok(buffer.read_with(&cx, |buffer, _| proto::BufferSaved { project_id, buffer_id, - version: serialize_version(&saved_version), - mtime: Some(mtime.into()), - fingerprint: language::proto::serialize_fingerprint(fingerprint), - }) + version: serialize_version(buffer.saved_version()), + mtime: Some(buffer.saved_mtime().into()), + fingerprint: language::proto::serialize_fingerprint(buffer.saved_version_fingerprint()), + })) } async fn handle_reload_buffers( diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index 97656b7b10053e4d6b7a30982719ebd75adab057..ee190e1a31940cefb17a875ea3445bd254ff2414 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -7,7 +7,7 @@ use client::{proto, Client}; use clock::ReplicaId; use collections::{HashMap, VecDeque}; use fs::{ - repository::{GitFileStatus, GitRepository, RepoPath, RepoPathDescendants}, + repository::{GitFileStatus, GitRepository, RepoPath}, Fs, LineEnding, }; use futures::{ @@ -45,7 +45,7 @@ use std::{ fmt, future::Future, mem, - ops::{Deref, DerefMut}, + ops::{AddAssign, Deref, DerefMut, Sub}, path::{Path, PathBuf}, pin::Pin, sync::{ @@ -55,7 +55,7 @@ use std::{ time::{Duration, SystemTime}, }; use sum_tree::{Bias, Edit, SeekTarget, SumTree, TreeMap, TreeSet}; -use util::{paths::HOME, ResultExt, TakeUntilExt}; +use util::{paths::HOME, ResultExt}; #[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] pub struct WorktreeId(usize); @@ -124,15 +124,6 @@ pub struct Snapshot { pub struct RepositoryEntry { pub(crate) work_directory: WorkDirectoryEntry, pub(crate) branch: Option>, - pub(crate) statuses: TreeMap, -} - -fn read_git_status(git_status: i32) -> Option { - proto::GitStatus::from_i32(git_status).map(|status| match status { - proto::GitStatus::Added => GitFileStatus::Added, - proto::GitStatus::Modified => GitFileStatus::Modified, - proto::GitStatus::Conflict => GitFileStatus::Conflict, - }) } impl RepositoryEntry { @@ -150,115 +141,19 @@ impl RepositoryEntry { .map(|entry| RepositoryWorkDirectory(entry.path.clone())) } - pub fn status_for_path(&self, snapshot: &Snapshot, path: &Path) -> Option { - self.work_directory - .relativize(snapshot, path) - .and_then(|repo_path| { - self.statuses - .iter_from(&repo_path) - .take_while(|(key, _)| key.starts_with(&repo_path)) - // Short circuit once we've found the highest level - .take_until(|(_, status)| status == &&GitFileStatus::Conflict) - .map(|(_, status)| status) - .reduce( - |status_first, status_second| match (status_first, status_second) { - (GitFileStatus::Conflict, _) | (_, GitFileStatus::Conflict) => { - &GitFileStatus::Conflict - } - (GitFileStatus::Modified, _) | (_, GitFileStatus::Modified) => { - &GitFileStatus::Modified - } - _ => &GitFileStatus::Added, - }, - ) - .copied() - }) - } - - #[cfg(any(test, feature = "test-support"))] - pub fn status_for_file(&self, snapshot: &Snapshot, path: &Path) -> Option { - self.work_directory - .relativize(snapshot, path) - .and_then(|repo_path| (&self.statuses).get(&repo_path)) - .cloned() - } - - pub fn build_update(&self, other: &Self) -> proto::RepositoryEntry { - let mut updated_statuses: Vec = Vec::new(); - let mut removed_statuses: Vec = Vec::new(); - - let mut self_statuses = self.statuses.iter().peekable(); - let mut other_statuses = other.statuses.iter().peekable(); - loop { - match (self_statuses.peek(), other_statuses.peek()) { - (Some((self_repo_path, self_status)), Some((other_repo_path, other_status))) => { - match Ord::cmp(self_repo_path, other_repo_path) { - Ordering::Less => { - updated_statuses.push(make_status_entry(self_repo_path, self_status)); - self_statuses.next(); - } - Ordering::Equal => { - if self_status != other_status { - updated_statuses - .push(make_status_entry(self_repo_path, self_status)); - } - - self_statuses.next(); - other_statuses.next(); - } - Ordering::Greater => { - removed_statuses.push(make_repo_path(other_repo_path)); - other_statuses.next(); - } - } - } - (Some((self_repo_path, self_status)), None) => { - updated_statuses.push(make_status_entry(self_repo_path, self_status)); - self_statuses.next(); - } - (None, Some((other_repo_path, _))) => { - removed_statuses.push(make_repo_path(other_repo_path)); - other_statuses.next(); - } - (None, None) => break, - } - } - + pub fn build_update(&self, _: &Self) -> proto::RepositoryEntry { proto::RepositoryEntry { work_directory_id: self.work_directory_id().to_proto(), branch: self.branch.as_ref().map(|str| str.to_string()), - removed_repo_paths: removed_statuses, - updated_statuses, } } } -fn make_repo_path(path: &RepoPath) -> String { - path.as_os_str().to_string_lossy().to_string() -} - -fn make_status_entry(path: &RepoPath, status: &GitFileStatus) -> proto::StatusEntry { - proto::StatusEntry { - repo_path: make_repo_path(path), - status: match status { - GitFileStatus::Added => proto::GitStatus::Added.into(), - GitFileStatus::Modified => proto::GitStatus::Modified.into(), - GitFileStatus::Conflict => proto::GitStatus::Conflict.into(), - }, - } -} - impl From<&RepositoryEntry> for proto::RepositoryEntry { fn from(value: &RepositoryEntry) -> Self { proto::RepositoryEntry { work_directory_id: value.work_directory.to_proto(), branch: value.branch.as_ref().map(|str| str.to_string()), - updated_statuses: value - .statuses - .iter() - .map(|(repo_path, status)| make_status_entry(repo_path, status)) - .collect(), - removed_repo_paths: Default::default(), } } } @@ -330,7 +225,6 @@ pub struct BackgroundScannerState { #[derive(Debug, Clone)] pub struct LocalRepositoryEntry { - pub(crate) work_dir_scan_id: usize, pub(crate) git_dir_scan_id: usize, pub(crate) repo_ptr: Arc>, /// Path to the actual .git folder. @@ -864,18 +758,13 @@ impl LocalWorktree { entry.path.clone(), GitRepositoryChange { old_repository: None, - git_dir_changed: true, }, )); } new_repos.next(); } Ordering::Equal => { - let git_dir_changed = - new_repo.git_dir_scan_id != old_repo.git_dir_scan_id; - let work_dir_changed = - new_repo.work_dir_scan_id != old_repo.work_dir_scan_id; - if git_dir_changed || work_dir_changed { + if new_repo.git_dir_scan_id != old_repo.git_dir_scan_id { if let Some(entry) = new_snapshot.entry_for_id(new_entry_id) { let old_repo = old_snapshot .repository_entries @@ -885,7 +774,6 @@ impl LocalWorktree { entry.path.clone(), GitRepositoryChange { old_repository: old_repo, - git_dir_changed, }, )); } @@ -903,7 +791,6 @@ impl LocalWorktree { entry.path.clone(), GitRepositoryChange { old_repository: old_repo, - git_dir_changed: true, }, )); } @@ -917,7 +804,6 @@ impl LocalWorktree { entry.path.clone(), GitRepositoryChange { old_repository: None, - git_dir_changed: true, }, )); } @@ -933,7 +819,6 @@ impl LocalWorktree { entry.path.clone(), GitRepositoryChange { old_repository: old_repo, - git_dir_changed: true, }, )); } @@ -1038,7 +923,7 @@ impl LocalWorktree { path: Arc, has_changed_file: bool, cx: &mut ModelContext, - ) -> Task> { + ) -> Task> { let handle = cx.handle(); let buffer = buffer_handle.read(cx); @@ -1094,7 +979,7 @@ impl LocalWorktree { buffer.did_save(version.clone(), fingerprint, entry.mtime, cx); }); - Ok((version, fingerprint, entry.mtime)) + Ok(()) }) } @@ -1405,7 +1290,7 @@ impl RemoteWorktree { &self, buffer_handle: ModelHandle, cx: &mut ModelContext, - ) -> Task> { + ) -> Task> { let buffer = buffer_handle.read(cx); let buffer_id = buffer.remote_id(); let version = buffer.version(); @@ -1430,7 +1315,7 @@ impl RemoteWorktree { buffer.did_save(version.clone(), fingerprint, mtime, cx); }); - Ok((version, fingerprint, mtime)) + Ok(()) }) } @@ -1574,7 +1459,7 @@ impl Snapshot { fn delete_entry(&mut self, entry_id: ProjectEntryId) -> Option> { let removed_entry = self.entries_by_id.remove(&entry_id, &())?; self.entries_by_path = { - let mut cursor = self.entries_by_path.cursor(); + let mut cursor = self.entries_by_path.cursor::(); let mut new_entries_by_path = cursor.slice(&TraversalTarget::Path(&removed_entry.path), Bias::Left, &()); while let Some(entry) = cursor.item() { @@ -1592,6 +1477,14 @@ impl Snapshot { Some(removed_entry.path) } + #[cfg(any(test, feature = "test-support"))] + pub fn status_for_file(&self, path: impl Into) -> Option { + let path = path.into(); + self.entries_by_path + .get(&PathKey(Arc::from(path)), &()) + .and_then(|entry| entry.git_status) + } + pub(crate) fn apply_remote_update(&mut self, mut update: proto::UpdateWorktree) -> Result<()> { let mut entries_by_path_edits = Vec::new(); let mut entries_by_id_edits = Vec::new(); @@ -1643,26 +1536,10 @@ impl Snapshot { ProjectEntryId::from_proto(repository.work_directory_id).into(); if let Some(entry) = self.entry_for_id(*work_directory_entry) { - let mut statuses = TreeMap::default(); - for status_entry in repository.updated_statuses { - let Some(git_file_status) = read_git_status(status_entry.status) else { - continue; - }; - - let repo_path = RepoPath::new(status_entry.repo_path.into()); - statuses.insert(repo_path, git_file_status); - } - let work_directory = RepositoryWorkDirectory(entry.path.clone()); if self.repository_entries.get(&work_directory).is_some() { self.repository_entries.update(&work_directory, |repo| { repo.branch = repository.branch.map(Into::into); - repo.statuses.insert_tree(statuses); - - for repo_path in repository.removed_repo_paths { - let repo_path = RepoPath::new(repo_path.into()); - repo.statuses.remove(&repo_path); - } }); } else { self.repository_entries.insert( @@ -1670,7 +1547,6 @@ impl Snapshot { RepositoryEntry { work_directory: work_directory_entry, branch: repository.branch.map(Into::into), - statuses, }, ) } @@ -1799,6 +1675,64 @@ impl Snapshot { }) } + /// Update the `git_status` of the given entries such that files' + /// statuses bubble up to their ancestor directories. + pub fn propagate_git_statuses(&self, result: &mut [Entry]) { + let mut cursor = self + .entries_by_path + .cursor::<(TraversalProgress, GitStatuses)>(); + let mut entry_stack = Vec::<(usize, GitStatuses)>::new(); + + let mut result_ix = 0; + loop { + let next_entry = result.get(result_ix); + let containing_entry = entry_stack.last().map(|(ix, _)| &result[*ix]); + + let entry_to_finish = match (containing_entry, next_entry) { + (Some(_), None) => entry_stack.pop(), + (Some(containing_entry), Some(next_path)) => { + if !next_path.path.starts_with(&containing_entry.path) { + entry_stack.pop() + } else { + None + } + } + (None, Some(_)) => None, + (None, None) => break, + }; + + if let Some((entry_ix, prev_statuses)) = entry_to_finish { + cursor.seek_forward( + &TraversalTarget::PathSuccessor(&result[entry_ix].path), + Bias::Left, + &(), + ); + + let statuses = cursor.start().1 - prev_statuses; + + result[entry_ix].git_status = if statuses.conflict > 0 { + Some(GitFileStatus::Conflict) + } else if statuses.modified > 0 { + Some(GitFileStatus::Modified) + } else if statuses.added > 0 { + Some(GitFileStatus::Added) + } else { + None + }; + } else { + if result[result_ix].is_dir() { + cursor.seek_forward( + &TraversalTarget::Path(&result[result_ix].path), + Bias::Left, + &(), + ); + entry_stack.push((result_ix, cursor.start().1)); + } + result_ix += 1; + } + } + } + pub fn paths(&self) -> impl Iterator> { let empty_path = Path::new(""); self.entries_by_path @@ -1895,6 +1829,14 @@ impl LocalSnapshot { self.git_repositories.get(&repo.work_directory.0) } + pub(crate) fn local_repo_for_path( + &self, + path: &Path, + ) -> Option<(RepositoryWorkDirectory, &LocalRepositoryEntry)> { + let (path, repo) = self.repository_and_work_directory_for_path(path)?; + Some((path, self.git_repositories.get(&repo.work_directory_id())?)) + } + pub(crate) fn repo_for_metadata( &self, path: &Path, @@ -2039,7 +1981,8 @@ impl LocalSnapshot { entry } - fn build_repo(&mut self, parent_path: Arc, fs: &dyn Fs) -> Option<()> { + #[must_use = "Changed paths must be used for diffing later"] + fn build_repo(&mut self, parent_path: Arc, fs: &dyn Fs) -> Option>> { let abs_path = self.abs_path.join(&parent_path); let work_dir: Arc = parent_path.parent().unwrap().into(); @@ -2056,35 +1999,67 @@ impl LocalSnapshot { .entry_for_path(work_dir.clone()) .map(|entry| entry.id)?; - if self.git_repositories.get(&work_dir_id).is_none() { - let repo = fs.open_repo(abs_path.as_path())?; - let work_directory = RepositoryWorkDirectory(work_dir.clone()); - let scan_id = self.scan_id; + if self.git_repositories.get(&work_dir_id).is_some() { + return None; + } - let repo_lock = repo.lock(); + let repo = fs.open_repo(abs_path.as_path())?; + let work_directory = RepositoryWorkDirectory(work_dir.clone()); - self.repository_entries.insert( - work_directory, - RepositoryEntry { - work_directory: work_dir_id.into(), - branch: repo_lock.branch_name().map(Into::into), - statuses: repo_lock.statuses().unwrap_or_default(), - }, - ); - drop(repo_lock); - - self.git_repositories.insert( - work_dir_id, - LocalRepositoryEntry { - work_dir_scan_id: scan_id, - git_dir_scan_id: scan_id, - repo_ptr: repo, - git_dir_path: parent_path.clone(), - }, - ) + let repo_lock = repo.lock(); + + self.repository_entries.insert( + work_directory.clone(), + RepositoryEntry { + work_directory: work_dir_id.into(), + branch: repo_lock.branch_name().map(Into::into), + }, + ); + + let changed_paths = self.scan_statuses(repo_lock.deref(), &work_directory); + + drop(repo_lock); + + self.git_repositories.insert( + work_dir_id, + LocalRepositoryEntry { + git_dir_scan_id: 0, + repo_ptr: repo, + git_dir_path: parent_path.clone(), + }, + ); + + Some(changed_paths) + } + + #[must_use = "Changed paths must be used for diffing later"] + fn scan_statuses( + &mut self, + repo_ptr: &dyn GitRepository, + work_directory: &RepositoryWorkDirectory, + ) -> Vec> { + let mut changes = vec![]; + let mut edits = vec![]; + for mut entry in self + .descendent_entries(false, false, &work_directory.0) + .cloned() + { + let Ok(repo_path) = entry.path.strip_prefix(&work_directory.0) else { + continue; + }; + let git_file_status = repo_ptr + .status(&RepoPath(repo_path.into())) + .log_err() + .flatten(); + if entry.git_status != git_file_status { + entry.git_status = git_file_status; + changes.push(entry.path.clone()); + edits.push(Edit::Insert(entry)); + } } - Some(()) + self.entries_by_path.edit(edits, &()); + changes } fn ancestor_inodes_for_path(&self, path: &Path) -> TreeSet { @@ -2139,13 +2114,14 @@ impl BackgroundScannerState { self.snapshot.insert_entry(entry, fs) } + #[must_use = "Changed paths must be used for diffing later"] fn populate_dir( &mut self, parent_path: Arc, entries: impl IntoIterator, ignore: Option>, fs: &dyn Fs, - ) { + ) -> Option>> { let mut parent_entry = if let Some(parent_entry) = self .snapshot .entries_by_path @@ -2157,7 +2133,7 @@ impl BackgroundScannerState { "populating a directory {:?} that has been removed", parent_path ); - return; + return None; }; match parent_entry.kind { @@ -2165,7 +2141,7 @@ impl BackgroundScannerState { parent_entry.kind = EntryKind::Dir; } EntryKind::Dir => {} - _ => return, + _ => return None, } if let Some(ignore) = ignore { @@ -2175,10 +2151,6 @@ impl BackgroundScannerState { .insert(abs_parent_path, (ignore, false)); } - if parent_path.file_name() == Some(&DOT_GIT) { - self.snapshot.build_repo(parent_path, fs); - } - let mut entries_by_path_edits = vec![Edit::Insert(parent_entry)]; let mut entries_by_id_edits = Vec::new(); @@ -2197,6 +2169,11 @@ impl BackgroundScannerState { .entries_by_path .edit(entries_by_path_edits, &()); self.snapshot.entries_by_id.edit(entries_by_id_edits, &()); + + if parent_path.file_name() == Some(&DOT_GIT) { + return self.snapshot.build_repo(parent_path, fs); + } + None } fn remove_path(&mut self, path: &Path) { @@ -2518,6 +2495,7 @@ pub struct Entry { pub mtime: SystemTime, pub is_symlink: bool, pub is_ignored: bool, + pub git_status: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -2546,9 +2524,6 @@ pub enum PathChange { pub struct GitRepositoryChange { /// The previous state of the repository, if it already existed. pub old_repository: Option, - /// Whether the content of the .git directory changed. This will be false - /// if only the repository's work directory changed. - pub git_dir_changed: bool, } pub type UpdatedEntriesSet = Arc<[(Arc, ProjectEntryId, PathChange)]>; @@ -2573,6 +2548,7 @@ impl Entry { mtime: metadata.mtime, is_symlink: metadata.is_symlink, is_ignored: false, + git_status: None, } } @@ -2583,6 +2559,10 @@ impl Entry { pub fn is_file(&self) -> bool { matches!(self.kind, EntryKind::File(_)) } + + pub fn git_status(&self) -> Option { + self.git_status + } } impl sum_tree::Item for Entry { @@ -2600,12 +2580,23 @@ impl sum_tree::Item for Entry { visible_file_count = 0; } + let mut statuses = GitStatuses::default(); + match self.git_status { + Some(status) => match status { + GitFileStatus::Added => statuses.added = 1, + GitFileStatus::Modified => statuses.modified = 1, + GitFileStatus::Conflict => statuses.conflict = 1, + }, + None => {} + } + EntrySummary { max_path: self.path.clone(), count: 1, visible_count, file_count, visible_file_count, + statuses, } } } @@ -2625,6 +2616,7 @@ pub struct EntrySummary { visible_count: usize, file_count: usize, visible_file_count: usize, + statuses: GitStatuses, } impl Default for EntrySummary { @@ -2635,6 +2627,7 @@ impl Default for EntrySummary { visible_count: 0, file_count: 0, visible_file_count: 0, + statuses: Default::default(), } } } @@ -2648,6 +2641,7 @@ impl sum_tree::Summary for EntrySummary { self.visible_count += rhs.visible_count; self.file_count += rhs.file_count; self.visible_file_count += rhs.visible_file_count; + self.statuses += rhs.statuses; } } @@ -2807,6 +2801,7 @@ impl BackgroundScanner { let mut state = self.state.lock(); state.snapshot.completed_scan_id = state.snapshot.scan_id; } + self.send_status_update(false, None); // Process any any FS events that occurred while performing the initial scan. @@ -2862,14 +2857,16 @@ impl BackgroundScanner { self.update_ignore_statuses().await; { - let mut snapshot = &mut self.state.lock().snapshot; + let mut state = self.state.lock(); if let Some(paths) = paths { for path in paths { - self.reload_repo_for_file_path(&path, &mut *snapshot, self.fs.as_ref()); + self.reload_git_repo(&path, &mut *state, self.fs.as_ref()); } } + let mut snapshot = &mut state.snapshot; + let mut git_repositories = mem::take(&mut snapshot.git_repositories); git_repositories.retain(|work_directory_id, _| { snapshot @@ -2993,14 +2990,18 @@ impl BackgroundScanner { let mut new_jobs: Vec> = Vec::new(); let mut ignore_stack = job.ignore_stack.clone(); let mut new_ignore = None; - let (root_abs_path, root_char_bag, next_entry_id) = { + let (root_abs_path, root_char_bag, next_entry_id, repository) = { let snapshot = &self.state.lock().snapshot; ( snapshot.abs_path().clone(), snapshot.root_char_bag, self.next_entry_id.clone(), + snapshot + .local_repo_for_path(&job.path) + .map(|(work_dir, repo)| (work_dir, repo.clone())), ) }; + let mut child_paths = self.fs.read_dir(&job.abs_path).await?; while let Some(child_abs_path) = child_paths.next().await { let child_abs_path: Arc = match child_abs_path { @@ -3093,6 +3094,18 @@ impl BackgroundScanner { } } else { child_entry.is_ignored = ignore_stack.is_abs_path_ignored(&child_abs_path, false); + if !child_entry.is_ignored { + if let Some((repo_path, repo)) = &repository { + if let Ok(path) = child_path.strip_prefix(&repo_path.0) { + child_entry.git_status = repo + .repo_ptr + .lock() + .status(&RepoPath(path.into())) + .log_err() + .flatten(); + } + } + } } new_entries.push(child_entry); @@ -3100,10 +3113,19 @@ impl BackgroundScanner { { let mut state = self.state.lock(); - state.populate_dir(job.path.clone(), new_entries, new_ignore, self.fs.as_ref()); + let changed_paths = + state.populate_dir(job.path.clone(), new_entries, new_ignore, self.fs.as_ref()); if let Err(ix) = state.changed_paths.binary_search(&job.path) { state.changed_paths.insert(ix, job.path.clone()); } + if let Some(changed_paths) = changed_paths { + util::extend_sorted( + &mut state.changed_paths, + changed_paths, + usize::MAX, + Ord::cmp, + ) + } } for new_job in new_jobs { @@ -3147,12 +3169,14 @@ impl BackgroundScanner { // refreshed. Do this before adding any new entries, so that renames can be // detected regardless of the order of the paths. let mut event_paths = Vec::>::with_capacity(abs_paths.len()); + let mut event_metadata = Vec::<_>::with_capacity(abs_paths.len()); for (abs_path, metadata) in abs_paths.iter().zip(metadata.iter()) { if let Ok(path) = abs_path.strip_prefix(&root_canonical_path) { if matches!(metadata, Ok(None)) || doing_recursive_update { state.remove_path(path); } event_paths.push(path.into()); + event_metadata.push(metadata); } else { log::error!( "unexpected event {:?} for root path {:?}", @@ -3162,7 +3186,7 @@ impl BackgroundScanner { } } - for (path, metadata) in event_paths.iter().cloned().zip(metadata.into_iter()) { + for (path, metadata) in event_paths.iter().cloned().zip(event_metadata.into_iter()) { let abs_path: Arc = root_abs_path.join(&path).into(); match metadata { @@ -3170,6 +3194,7 @@ impl BackgroundScanner { let ignore_stack = state .snapshot .ignore_stack_for_abs_path(&abs_path, metadata.is_dir); + let mut fs_entry = Entry::new( path.clone(), &metadata, @@ -3177,6 +3202,24 @@ impl BackgroundScanner { state.snapshot.root_char_bag, ); fs_entry.is_ignored = ignore_stack.is_all(); + + if !fs_entry.is_ignored { + if !fs_entry.is_dir() { + if let Some((work_dir, repo)) = + state.snapshot.local_repo_for_path(&path) + { + if let Ok(path) = path.strip_prefix(work_dir.0) { + fs_entry.git_status = repo + .repo_ptr + .lock() + .status(&RepoPath(path.into())) + .log_err() + .flatten() + } + } + } + } + state.insert_entry(fs_entry, self.fs.as_ref()); if let Some(scan_queue_tx) = &scan_queue_tx { @@ -3219,8 +3262,6 @@ impl BackgroundScanner { .components() .any(|component| component.as_os_str() == *DOT_GIT) { - let scan_id = snapshot.scan_id; - if let Some(repository) = snapshot.repository_for_work_directory(path) { let entry = repository.work_directory.0; snapshot.git_repositories.remove(&entry); @@ -3230,113 +3271,80 @@ impl BackgroundScanner { .remove(&RepositoryWorkDirectory(path.into())); return Some(()); } - - let repo = snapshot.repository_for_path(&path)?; - let repo_path = repo.work_directory.relativize(&snapshot, &path)?; - let work_dir = repo.work_directory(snapshot)?; - let work_dir_id = repo.work_directory; - - snapshot - .git_repositories - .update(&work_dir_id, |entry| entry.work_dir_scan_id = scan_id); - - snapshot.repository_entries.update(&work_dir, |entry| { - entry - .statuses - .remove_range(&repo_path, &RepoPathDescendants(&repo_path)) - }); } + // TODO statuses + // Track when a .git is removed and iterate over the file system there + Some(()) } - fn reload_repo_for_file_path( + fn reload_git_repo( &self, path: &Path, - snapshot: &mut LocalSnapshot, + state: &mut BackgroundScannerState, fs: &dyn Fs, ) -> Option<()> { - let scan_id = snapshot.scan_id; + let scan_id = state.snapshot.scan_id; if path .components() .any(|component| component.as_os_str() == *DOT_GIT) { let (entry_id, repo_ptr) = { - let Some((entry_id, repo)) = snapshot.repo_for_metadata(&path) else { + let Some((entry_id, repo)) = state.snapshot.repo_for_metadata(&path) else { let dot_git_dir = path.ancestors() .skip_while(|ancestor| ancestor.file_name() != Some(&*DOT_GIT)) .next()?; - snapshot.build_repo(dot_git_dir.into(), fs); + let changed_paths = state.snapshot.build_repo(dot_git_dir.into(), fs); + if let Some(changed_paths) = changed_paths { + util::extend_sorted( + &mut state.changed_paths, + changed_paths, + usize::MAX, + Ord::cmp, + ); + } + return None; }; if repo.git_dir_scan_id == scan_id { return None; } + (*entry_id, repo.repo_ptr.to_owned()) }; - let work_dir = snapshot + let work_dir = state + .snapshot .entry_for_id(entry_id) .map(|entry| RepositoryWorkDirectory(entry.path.clone()))?; let repo = repo_ptr.lock(); repo.reload_index(); let branch = repo.branch_name(); - let statuses = repo.statuses().unwrap_or_default(); - snapshot.git_repositories.update(&entry_id, |entry| { - entry.work_dir_scan_id = scan_id; + state.snapshot.git_repositories.update(&entry_id, |entry| { entry.git_dir_scan_id = scan_id; }); - snapshot.repository_entries.update(&work_dir, |entry| { - entry.branch = branch.map(Into::into); - entry.statuses = statuses; - }); - } else { - if snapshot - .entry_for_path(&path) - .map(|entry| entry.is_ignored) - .unwrap_or(false) - { - self.remove_repo_path(&path, snapshot); - return None; - } - - let repo = snapshot.repository_for_path(&path)?; - - let work_dir = repo.work_directory(snapshot)?; - let work_dir_id = repo.work_directory.clone(); - - let (local_repo, git_dir_scan_id) = - snapshot.git_repositories.update(&work_dir_id, |entry| { - entry.work_dir_scan_id = scan_id; - (entry.repo_ptr.clone(), entry.git_dir_scan_id) - })?; - - // Short circuit if we've already scanned everything - if git_dir_scan_id == scan_id { - return None; - } - - let mut repository = snapshot.repository_entries.remove(&work_dir)?; - - for entry in snapshot.descendent_entries(false, false, path) { - let Some(repo_path) = repo.work_directory.relativize(snapshot, &entry.path) else { - continue; - }; + state + .snapshot + .snapshot + .repository_entries + .update(&work_dir, |entry| { + entry.branch = branch.map(Into::into); + }); - let status = local_repo.lock().status(&repo_path); - if let Some(status) = status { - repository.statuses.insert(repo_path.clone(), status); - } else { - repository.statuses.remove(&repo_path); - } - } + let changed_paths = state.snapshot.scan_statuses(repo.deref(), &work_dir); - snapshot.repository_entries.insert(work_dir, repository) + util::extend_sorted( + &mut state.changed_paths, + changed_paths, + usize::MAX, + Ord::cmp, + ) } Some(()) @@ -3501,7 +3509,6 @@ impl BackgroundScanner { if new_paths.item().map_or(false, |e| e.path < path.0) { new_paths.seek_forward(&path, Bias::Left, &()); } - loop { match (old_paths.item(), new_paths.item()) { (Some(old_entry), Some(new_entry)) => { @@ -3520,6 +3527,13 @@ impl BackgroundScanner { } Ordering::Equal => { if self.phase == EventsReceivedDuringInitialScan { + if old_entry.id != new_entry.id { + changes.push(( + old_entry.path.clone(), + old_entry.id, + Removed, + )); + } // If the worktree was not fully initialized when this event was generated, // we can't know whether this entry was added during the scan or whether // it was merely updated. @@ -3702,6 +3716,39 @@ impl<'a> Default for TraversalProgress<'a> { } } +#[derive(Clone, Debug, Default, Copy)] +struct GitStatuses { + added: usize, + modified: usize, + conflict: usize, +} + +impl AddAssign for GitStatuses { + fn add_assign(&mut self, rhs: Self) { + self.added += rhs.added; + self.modified += rhs.modified; + self.conflict += rhs.conflict; + } +} + +impl Sub for GitStatuses { + type Output = GitStatuses; + + fn sub(self, rhs: Self) -> Self::Output { + GitStatuses { + added: self.added - rhs.added, + modified: self.modified - rhs.modified, + conflict: self.conflict - rhs.conflict, + } + } +} + +impl<'a> sum_tree::Dimension<'a, EntrySummary> for GitStatuses { + fn add_summary(&mut self, summary: &'a EntrySummary, _: &()) { + *self += summary.statuses + } +} + pub struct Traversal<'a> { cursor: sum_tree::Cursor<'a, Entry, TraversalProgress<'a>>, include_ignored: bool, @@ -3803,6 +3850,14 @@ impl<'a, 'b> SeekTarget<'a, EntrySummary, TraversalProgress<'a>> for TraversalTa } } +impl<'a, 'b> SeekTarget<'a, EntrySummary, (TraversalProgress<'a>, GitStatuses)> + for TraversalTarget<'b> +{ + fn cmp(&self, cursor_location: &(TraversalProgress<'a>, GitStatuses), _: &()) -> Ordering { + self.cmp(&cursor_location.0, &()) + } +} + struct ChildEntriesIter<'a> { parent_path: &'a Path, traversal: Traversal<'a>, @@ -3851,6 +3906,7 @@ impl<'a> From<&'a Entry> for proto::Entry { mtime: Some(entry.mtime.into()), is_symlink: entry.is_symlink, is_ignored: entry.is_ignored, + git_status: entry.git_status.map(|status| status.to_proto()), } } } @@ -3876,6 +3932,7 @@ impl<'a> TryFrom<(&'a CharBag, proto::Entry)> for Entry { mtime: mtime.into(), is_symlink: entry.is_symlink, is_ignored: entry.is_ignored, + git_status: GitFileStatus::from_proto(entry.git_status), }) } else { Err(anyhow!( @@ -4656,7 +4713,7 @@ mod tests { log::info!("mutating fs"); let mut files = Vec::new(); let mut dirs = Vec::new(); - for path in fs.as_fake().paths() { + for path in fs.as_fake().paths(false) { if path.starts_with(root_path) { if fs.is_file(&path).await { files.push(path); @@ -4931,14 +4988,14 @@ mod tests { cx.read(|cx| { let tree = tree.read(cx); - let (work_dir, repo) = tree.repositories().next().unwrap(); + let (work_dir, _) = tree.repositories().next().unwrap(); assert_eq!(work_dir.as_ref(), Path::new("projects/project1")); assert_eq!( - repo.status_for_file(tree, Path::new("projects/project1/a")), + tree.status_for_file(Path::new("projects/project1/a")), Some(GitFileStatus::Modified) ); assert_eq!( - repo.status_for_file(tree, Path::new("projects/project1/b")), + tree.status_for_file(Path::new("projects/project1/b")), Some(GitFileStatus::Added) ); }); @@ -4952,14 +5009,14 @@ mod tests { cx.read(|cx| { let tree = tree.read(cx); - let (work_dir, repo) = tree.repositories().next().unwrap(); + let (work_dir, _) = tree.repositories().next().unwrap(); assert_eq!(work_dir.as_ref(), Path::new("projects/project2")); assert_eq!( - repo.status_for_file(tree, Path::new("projects/project2/a")), + tree.status_for_file(Path::new("projects/project2/a")), Some(GitFileStatus::Modified) ); assert_eq!( - repo.status_for_file(tree, Path::new("projects/project2/b")), + tree.status_for_file(Path::new("projects/project2/b")), Some(GitFileStatus::Added) ); }); @@ -5088,7 +5145,7 @@ mod tests { } #[gpui::test] - async fn test_git_status(cx: &mut TestAppContext) { + async fn test_git_status(deterministic: Arc, cx: &mut TestAppContext) { const IGNORE_RULE: &'static str = "**/target"; let root = temp_tree(json!({ @@ -5131,6 +5188,7 @@ mod tests { 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()); @@ -5140,29 +5198,37 @@ mod tests { git_add(Path::new(DOTGITIGNORE), &repo); git_commit("Initial commit", &repo); - std::fs::write(work_dir.join(A_TXT), "aa").unwrap(); - 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, repo) = snapshot.repository_entries.iter().next().unwrap(); + let (dir, _) = snapshot.repository_entries.iter().next().unwrap(); assert_eq!(dir.0.as_ref(), Path::new("project")); - assert_eq!(repo.statuses.iter().count(), 3); assert_eq!( - repo.statuses.get(&Path::new(A_TXT).into()), - Some(&GitFileStatus::Modified) + snapshot.status_for_file(project_path.join(B_TXT)), + Some(GitFileStatus::Added) ); assert_eq!( - repo.statuses.get(&Path::new(B_TXT).into()), - Some(&GitFileStatus::Added) + 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!( - repo.statuses.get(&Path::new(F_TXT).into()), - Some(&GitFileStatus::Added) + snapshot.status_for_file(project_path.join(A_TXT)), + Some(GitFileStatus::Modified) ); }); @@ -5170,17 +5236,19 @@ mod tests { 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(); - let (_, repo) = snapshot.repository_entries.iter().next().unwrap(); - assert_eq!(repo.statuses.iter().count(), 1); assert_eq!( - repo.statuses.get(&Path::new(F_TXT).into()), - Some(&GitFileStatus::Added) + 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); @@ -5189,25 +5257,20 @@ mod tests { 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(); - let (_, repo) = snapshot.repository_entries.iter().next().unwrap(); - assert_eq!(repo.statuses.iter().count(), 3); - assert_eq!(repo.statuses.get(&Path::new(A_TXT).into()), None); + assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None); assert_eq!( - repo.statuses.get(&Path::new(B_TXT).into()), - Some(&GitFileStatus::Added) - ); - assert_eq!( - repo.statuses.get(&Path::new(E_TXT).into()), - Some(&GitFileStatus::Modified) + snapshot.status_for_file(project_path.join(B_TXT)), + Some(GitFileStatus::Added) ); assert_eq!( - repo.statuses.get(&Path::new(F_TXT).into()), - Some(&GitFileStatus::Added) + snapshot.status_for_file(project_path.join(E_TXT)), + Some(GitFileStatus::Modified) ); }); @@ -5223,14 +5286,7 @@ mod tests { git_commit("Committing modified git ignore", &repo); tree.flush_fs_events(cx).await; - - // Check that non-repo behavior is tracked - tree.read_with(cx, |tree, _cx| { - let snapshot = tree.snapshot(); - let (_, repo) = snapshot.repository_entries.iter().next().unwrap(); - - assert_eq!(repo.statuses.iter().count(), 0); - }); + deterministic.run_until_parked(); let mut renamed_dir_name = "first_directory/second_directory"; const RENAMED_FILE: &'static str = "rf.txt"; @@ -5243,16 +5299,14 @@ mod tests { .unwrap(); tree.flush_fs_events(cx).await; + deterministic.run_until_parked(); tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let (_, repo) = snapshot.repository_entries.iter().next().unwrap(); - - assert_eq!(repo.statuses.iter().count(), 1); assert_eq!( - repo.statuses - .get(&Path::new(renamed_dir_name).join(RENAMED_FILE).into()), - Some(&GitFileStatus::Added) + snapshot + .status_for_file(&project_path.join(renamed_dir_name).join(RENAMED_FILE)), + Some(GitFileStatus::Added) ); }); @@ -5265,20 +5319,143 @@ mod tests { .unwrap(); tree.flush_fs_events(cx).await; + deterministic.run_until_parked(); tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let (_, repo) = snapshot.repository_entries.iter().next().unwrap(); - assert_eq!(repo.statuses.iter().count(), 1); assert_eq!( - repo.statuses - .get(&Path::new(renamed_dir_name).join(RENAMED_FILE).into()), - Some(&GitFileStatus::Added) + 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") diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 60af95174f8c35a2a466306bc568079ba8930b64..9563d54be8b05233ed3df121efb2de9a37b0d5b7 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1002,6 +1002,7 @@ impl ProjectPanel { mtime: entry.mtime, is_symlink: false, is_ignored: false, + git_status: entry.git_status, }); } if expanded_dir_ids.binary_search(&entry.id).is_err() @@ -1011,6 +1012,9 @@ impl ProjectPanel { } entry_iter.advance(); } + + snapshot.propagate_git_statuses(&mut visible_worktree_entries); + visible_worktree_entries.sort_by(|entry_a, entry_b| { let mut components_a = entry_a.path.components().peekable(); let mut components_b = entry_b.path.components().peekable(); @@ -1108,14 +1112,8 @@ impl ProjectPanel { .unwrap_or(&[]); let entry_range = range.start.saturating_sub(ix)..end_ix - ix; - for (entry, repo) in - snapshot.entries_with_repositories(visible_worktree_entries[entry_range].iter()) - { - let status = (git_status_setting - && entry.path.parent().is_some() - && !entry.is_ignored) - .then(|| repo.and_then(|repo| repo.status_for_path(&snapshot, &entry.path))) - .flatten(); + for entry in visible_worktree_entries[entry_range].iter() { + let status = git_status_setting.then(|| entry.git_status).flatten(); let mut details = EntryDetails { filename: entry diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index ca3ec7cafb899a8ccbec6aec548c79d97ea30daa..ce4dd7f7cf5514fa56aa4a62f1ed4553a9273b54 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -1005,13 +1005,12 @@ message Entry { Timestamp mtime = 5; bool is_symlink = 6; bool is_ignored = 7; + optional GitStatus git_status = 8; } message RepositoryEntry { uint64 work_directory_id = 1; optional string branch = 2; - repeated string removed_repo_paths = 3; - repeated StatusEntry updated_statuses = 4; } message StatusEntry { diff --git a/crates/sum_tree/src/sum_tree.rs b/crates/sum_tree/src/sum_tree.rs index 36f0f926cd60e6a9d726d24297fb15d4a22a6d6c..6c6150aa3ab45f9b7467a30ea060fbfb0fb27e69 100644 --- a/crates/sum_tree/src/sum_tree.rs +++ b/crates/sum_tree/src/sum_tree.rs @@ -480,6 +480,11 @@ impl SumTree { } => child_trees.last().unwrap().rightmost_leaf(), } } + + #[cfg(debug_assertions)] + pub fn _debug_entries(&self) -> Vec<&T> { + self.iter().collect::>() + } } impl PartialEq for SumTree { diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 79f3c68514c11eadf62acd8ebf1d805085739eb8..71ffacebf34e2474bcb1c394dada124827c53477 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -57,7 +57,7 @@ pub fn post_inc + AddAssign + Copy>(value: &mut T) -> T { } /// Extend a sorted vector with a sorted sequence of items, maintaining the vector's sort order and -/// enforcing a maximum length. Sort the items according to the given callback. Before calling this, +/// enforcing a maximum length. This also de-duplicates items. Sort the items according to the given callback. Before calling this, /// both `vec` and `new_items` should already be sorted according to the `cmp` comparator. pub fn extend_sorted(vec: &mut Vec, new_items: I, limit: usize, mut cmp: F) where