diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index 4af1355352554ee6e3350806cefe0b4cd41cf5d6..a64233caba014aa49bd64f98634b40abeef88e8e 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -394,29 +394,29 @@ async fn test_linked_worktrees_sync( ) .await; - client_a - .fs() - .with_git_state(Path::new(path!("/project/.git")), true, |state| { - state.worktrees.push(GitWorktree { - path: PathBuf::from(path!("/project")), - ref_name: Some("refs/heads/main".into()), - sha: "aaa111".into(), - is_main: false, - }); - state.worktrees.push(GitWorktree { - path: PathBuf::from(path!("/project/feature-branch")), - ref_name: Some("refs/heads/feature-branch".into()), - sha: "bbb222".into(), - is_main: false, - }); - state.worktrees.push(GitWorktree { - path: PathBuf::from(path!("/project/bugfix-branch")), - ref_name: Some("refs/heads/bugfix-branch".into()), - sha: "ccc333".into(), - is_main: false, - }); - }) - .unwrap(); + let fs = client_a.fs(); + fs.add_linked_worktree_for_repo( + Path::new(path!("/project/.git")), + true, + GitWorktree { + path: PathBuf::from(path!("/worktrees/feature-branch")), + ref_name: Some("refs/heads/feature-branch".into()), + sha: "bbb222".into(), + is_main: false, + }, + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new(path!("/project/.git")), + true, + GitWorktree { + path: PathBuf::from(path!("/worktrees/bugfix-branch")), + ref_name: Some("refs/heads/bugfix-branch".into()), + sha: "ccc333".into(), + is_main: false, + }, + ) + .await; let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await; @@ -437,22 +437,22 @@ async fn test_linked_worktrees_sync( ); assert_eq!( host_linked[0].path, - PathBuf::from(path!("/project/feature-branch")) + PathBuf::from(path!("/worktrees/bugfix-branch")) ); assert_eq!( host_linked[0].ref_name, - Some("refs/heads/feature-branch".into()) + Some("refs/heads/bugfix-branch".into()) ); - assert_eq!(host_linked[0].sha.as_ref(), "bbb222"); + assert_eq!(host_linked[0].sha.as_ref(), "ccc333"); assert_eq!( host_linked[1].path, - PathBuf::from(path!("/project/bugfix-branch")) + PathBuf::from(path!("/worktrees/feature-branch")) ); assert_eq!( host_linked[1].ref_name, - Some("refs/heads/bugfix-branch".into()) + Some("refs/heads/feature-branch".into()) ); - assert_eq!(host_linked[1].sha.as_ref(), "ccc333"); + assert_eq!(host_linked[1].sha.as_ref(), "bbb222"); // Share the project and have client B join. let project_id = active_call_a @@ -478,15 +478,17 @@ async fn test_linked_worktrees_sync( // Now mutate: add a third linked worktree on the host side. client_a .fs() - .with_git_state(Path::new(path!("/project/.git")), true, |state| { - state.worktrees.push(GitWorktree { - path: PathBuf::from(path!("/project/hotfix-branch")), + .add_linked_worktree_for_repo( + Path::new(path!("/project/.git")), + true, + GitWorktree { + path: PathBuf::from(path!("/worktrees/hotfix-branch")), ref_name: Some("refs/heads/hotfix-branch".into()), sha: "ddd444".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; // Wait for the host to re-scan and propagate the update. executor.run_until_parked(); @@ -504,7 +506,7 @@ async fn test_linked_worktrees_sync( ); assert_eq!( host_linked_updated[2].path, - PathBuf::from(path!("/project/hotfix-branch")) + PathBuf::from(path!("/worktrees/hotfix-branch")) ); // Verify the guest also received the update. @@ -521,12 +523,12 @@ async fn test_linked_worktrees_sync( // Now mutate: remove one linked worktree from the host side. client_a .fs() - .with_git_state(Path::new(path!("/project/.git")), true, |state| { - state - .worktrees - .retain(|wt| wt.ref_name != Some("refs/heads/bugfix-branch".into())); - }) - .unwrap(); + .remove_worktree_for_repo( + Path::new(path!("/project/.git")), + true, + "refs/heads/bugfix-branch", + ) + .await; executor.run_until_parked(); diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs index fe93a06f7265d102d8727466c46e83daf066e506..0796323fc5b3d8f6b1cbcb0e108a7d573240f446 100644 --- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs +++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs @@ -469,7 +469,7 @@ async fn test_ssh_collaboration_git_worktrees( .unwrap(); assert_eq!(worktrees.len(), 1); - let worktree_directory = PathBuf::from("/project"); + let worktree_directory = PathBuf::from("/worktrees"); cx_b.update(|cx| { repo_b.update(cx, |repo, _| { repo.create_worktree( @@ -536,8 +536,8 @@ async fn test_ssh_collaboration_git_worktrees( cx_a.update(|cx| { repo_a.update(cx, |repository, _| { repository.rename_worktree( - PathBuf::from("/project/feature-branch"), - PathBuf::from("/project/renamed-branch"), + PathBuf::from("/worktrees/feature-branch"), + PathBuf::from("/worktrees/renamed-branch"), ) }) }) @@ -559,7 +559,7 @@ async fn test_ssh_collaboration_git_worktrees( ); assert_eq!( host_worktrees[1].path, - PathBuf::from("/project/renamed-branch") + PathBuf::from("/worktrees/renamed-branch") ); let server_worktrees = { @@ -588,13 +588,13 @@ async fn test_ssh_collaboration_git_worktrees( ); assert_eq!( server_worktrees[1].path, - PathBuf::from("/project/renamed-branch") + PathBuf::from("/worktrees/renamed-branch") ); // Host (client A) removes the renamed worktree via SSH cx_a.update(|cx| { repo_a.update(cx, |repository, _| { - repository.remove_worktree(PathBuf::from("/project/renamed-branch"), false) + repository.remove_worktree(PathBuf::from("/worktrees/renamed-branch"), false) }) }) .await diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 12a095ffe27aa760623fa2b6ce674fdd9008eef1..fc66e27fc9a32c2a8897eb5c9faee917c21177c5 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -53,7 +53,6 @@ pub struct FakeGitRepositoryState { pub simulated_create_worktree_error: Option, pub refs: HashMap, pub graph_commits: Vec>, - pub worktrees: Vec, } impl FakeGitRepositoryState { @@ -73,7 +72,6 @@ impl FakeGitRepositoryState { oids: Default::default(), remotes: HashMap::default(), graph_commits: Vec::new(), - worktrees: Vec::new(), } } } @@ -409,32 +407,78 @@ impl GitRepository for FakeGitRepository { } fn worktrees(&self) -> BoxFuture<'_, Result>> { - let dot_git_path = self.dot_git_path.clone(); - self.with_state_async(false, move |state| { - let work_dir = dot_git_path - .parent() - .map(PathBuf::from) - .unwrap_or(dot_git_path); - let head_sha = state - .refs - .get("HEAD") - .cloned() - .unwrap_or_else(|| "0000000".to_string()); - let branch_ref = state - .current_branch_name - .as_ref() - .map(|name| format!("refs/heads/{name}")) - .unwrap_or_else(|| "refs/heads/main".to_string()); - let main_worktree = Worktree { - path: work_dir, - ref_name: Some(branch_ref.into()), - sha: head_sha.into(), - is_main: true, - }; + let fs = self.fs.clone(); + let common_dir_path = self.common_dir_path.clone(); + let executor = self.executor.clone(); + + async move { + executor.simulate_random_delay().await; + + let (main_worktree, refs) = fs.with_git_state(&common_dir_path, false, |state| { + let work_dir = common_dir_path + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| common_dir_path.clone()); + let head_sha = state + .refs + .get("HEAD") + .cloned() + .unwrap_or_else(|| "0000000".to_string()); + let branch_ref = state + .current_branch_name + .as_ref() + .map(|name| format!("refs/heads/{name}")) + .unwrap_or_else(|| "refs/heads/main".to_string()); + let main_wt = Worktree { + path: work_dir, + ref_name: Some(branch_ref.into()), + sha: head_sha.into(), + is_main: true, + }; + (main_wt, state.refs.clone()) + })?; + let mut all = vec![main_worktree]; - all.extend(state.worktrees.iter().cloned()); + + let worktrees_dir = common_dir_path.join("worktrees"); + if let Ok(mut entries) = fs.read_dir(&worktrees_dir).await { + use futures::StreamExt; + while let Some(Ok(entry_path)) = entries.next().await { + let head_content = match fs.load(&entry_path.join("HEAD")).await { + Ok(content) => content, + Err(_) => continue, + }; + let gitdir_content = match fs.load(&entry_path.join("gitdir")).await { + Ok(content) => content, + Err(_) => continue, + }; + + let ref_name = head_content + .strip_prefix("ref: ") + .map(|s| s.trim().to_string()); + let sha = ref_name + .as_ref() + .and_then(|r| refs.get(r)) + .cloned() + .unwrap_or_else(|| head_content.trim().to_string()); + + let worktree_path = PathBuf::from(gitdir_content.trim()) + .parent() + .map(PathBuf::from) + .unwrap_or_default(); + + all.push(Worktree { + path: worktree_path, + ref_name: ref_name.map(Into::into), + sha: sha.into(), + is_main: false, + }); + } + } + Ok(all) - }) + } + .boxed() } fn create_worktree( @@ -446,36 +490,58 @@ impl GitRepository for FakeGitRepository { let fs = self.fs.clone(); let executor = self.executor.clone(); let dot_git_path = self.dot_git_path.clone(); + let common_dir_path = self.common_dir_path.clone(); async move { executor.simulate_random_delay().await; - // Check for simulated error before any side effects + // Check for simulated error and duplicate branch before any side effects. fs.with_git_state(&dot_git_path, false, |state| { if let Some(message) = &state.simulated_create_worktree_error { anyhow::bail!("{message}"); } + if state.branches.contains(&branch_name) { + bail!("a branch named '{}' already exists", branch_name); + } Ok(()) })??; - // Create directory before updating state so state is never - // inconsistent with the filesystem + + // Create the worktree checkout directory. fs.create_dir(&path).await?; - fs.with_git_state(&dot_git_path, true, { - let path = path.clone(); - move |state| { - if state.branches.contains(&branch_name) { - bail!("a branch named '{}' already exists", branch_name); - } - let ref_name = format!("refs/heads/{branch_name}"); - let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string()); - state.refs.insert(ref_name.clone(), sha.clone()); - state.worktrees.push(Worktree { - path, - ref_name: Some(ref_name.into()), - sha: sha.into(), - is_main: false, - }); - state.branches.insert(branch_name); - Ok::<(), anyhow::Error>(()) - } + + // Create .git/worktrees// directory with HEAD, commondir, gitdir. + let ref_name = format!("refs/heads/{branch_name}"); + let worktrees_entry_dir = common_dir_path.join("worktrees").join(&branch_name); + fs.create_dir(&worktrees_entry_dir).await?; + + fs.write_file_internal( + worktrees_entry_dir.join("HEAD"), + format!("ref: {ref_name}").into_bytes(), + false, + )?; + fs.write_file_internal( + worktrees_entry_dir.join("commondir"), + common_dir_path.to_string_lossy().into_owned().into_bytes(), + false, + )?; + let worktree_dot_git = path.join(".git"); + fs.write_file_internal( + worktrees_entry_dir.join("gitdir"), + worktree_dot_git.to_string_lossy().into_owned().into_bytes(), + false, + )?; + + // Create .git file in the worktree checkout. + fs.write_file_internal( + &worktree_dot_git, + format!("gitdir: {}", worktrees_entry_dir.display()).into_bytes(), + false, + )?; + + // Update git state: add ref and branch. + let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string()); + fs.with_git_state(&dot_git_path, true, move |state| { + state.refs.insert(ref_name, sha); + state.branches.insert(branch_name); + Ok::<(), anyhow::Error>(()) })??; Ok(()) } @@ -485,20 +551,23 @@ impl GitRepository for FakeGitRepository { fn remove_worktree(&self, path: PathBuf, _force: bool) -> BoxFuture<'_, Result<()>> { let fs = self.fs.clone(); let executor = self.executor.clone(); - let dot_git_path = self.dot_git_path.clone(); + let common_dir_path = self.common_dir_path.clone(); async move { executor.simulate_random_delay().await; - // Validate the worktree exists in state before touching the filesystem - fs.with_git_state(&dot_git_path, false, { - let path = path.clone(); - move |state| { - if !state.worktrees.iter().any(|w| w.path == path) { - bail!("no worktree found at path: {}", path.display()); - } - Ok(()) - } - })??; - // Now remove the directory + + // Read the worktree's .git file to find its entry directory. + let dot_git_file = path.join(".git"); + let content = fs + .load(&dot_git_file) + .await + .with_context(|| format!("no worktree found at path: {}", path.display()))?; + let gitdir = content + .strip_prefix("gitdir:") + .context("invalid .git file in worktree")? + .trim(); + let worktree_entry_dir = PathBuf::from(gitdir); + + // Remove the worktree checkout directory. fs.remove_dir( &path, RemoveOptions { @@ -507,11 +576,21 @@ impl GitRepository for FakeGitRepository { }, ) .await?; - // Update state - fs.with_git_state(&dot_git_path, true, move |state| { - state.worktrees.retain(|worktree| worktree.path != path); - Ok::<(), anyhow::Error>(()) - })??; + + // Remove the .git/worktrees// directory. + fs.remove_dir( + &worktree_entry_dir, + RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await?; + + // Emit a git event on the main .git directory so the scanner + // notices the change. + fs.with_git_state(&common_dir_path, true, |_| {})?; + Ok(()) } .boxed() @@ -520,20 +599,23 @@ impl GitRepository for FakeGitRepository { fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> { let fs = self.fs.clone(); let executor = self.executor.clone(); - let dot_git_path = self.dot_git_path.clone(); + let common_dir_path = self.common_dir_path.clone(); async move { executor.simulate_random_delay().await; - // Validate the worktree exists in state before touching the filesystem - fs.with_git_state(&dot_git_path, false, { - let old_path = old_path.clone(); - move |state| { - if !state.worktrees.iter().any(|w| w.path == old_path) { - bail!("no worktree found at path: {}", old_path.display()); - } - Ok(()) - } - })??; - // Now move the directory + + // Read the worktree's .git file to find its entry directory. + let dot_git_file = old_path.join(".git"); + let content = fs + .load(&dot_git_file) + .await + .with_context(|| format!("no worktree found at path: {}", old_path.display()))?; + let gitdir = content + .strip_prefix("gitdir:") + .context("invalid .git file in worktree")? + .trim(); + let worktree_entry_dir = PathBuf::from(gitdir); + + // Move the worktree checkout directory. fs.rename( &old_path, &new_path, @@ -544,16 +626,27 @@ impl GitRepository for FakeGitRepository { }, ) .await?; - // Update state - fs.with_git_state(&dot_git_path, true, move |state| { - let worktree = state - .worktrees - .iter_mut() - .find(|worktree| worktree.path == old_path) - .expect("worktree was validated above"); - worktree.path = new_path; - Ok::<(), anyhow::Error>(()) - })??; + + // Update the gitdir file in .git/worktrees// to point to the + // new location. + let new_dot_git = new_path.join(".git"); + fs.write_file_internal( + worktree_entry_dir.join("gitdir"), + new_dot_git.to_string_lossy().into_owned().into_bytes(), + false, + )?; + + // Update the .git file in the moved worktree checkout. + fs.write_file_internal( + &new_dot_git, + format!("gitdir: {}", worktree_entry_dir.display()).into_bytes(), + false, + )?; + + // Emit a git event on the main .git directory so the scanner + // notices the change. + fs.with_git_state(&common_dir_path, true, |_| {})?; + Ok(()) } .boxed() diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 99efafadc0421791c526bfe80a751d186de4ff8a..a26abb81255003e4059f9bcc8a68aa3c6212a73a 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -57,7 +57,7 @@ use collections::{BTreeMap, btree_map}; use fake_git_repo::FakeGitRepositoryState; #[cfg(feature = "test-support")] use git::{ - repository::{InitialGraphCommitData, RepoPath, repo_path}, + repository::{InitialGraphCommitData, RepoPath, Worktree, repo_path}, status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus}, }; #[cfg(feature = "test-support")] @@ -1892,11 +1892,15 @@ impl FakeFs { anyhow::bail!("gitfile points to a non-directory") }; let common_dir = if let Some(child) = entries.get("commondir") { - Path::new( - std::str::from_utf8(child.file_content("commondir".as_ref())?) - .context("commondir content")?, - ) - .to_owned() + let raw = std::str::from_utf8(child.file_content("commondir".as_ref())?) + .context("commondir content")? + .trim(); + let raw_path = Path::new(raw); + if raw_path.is_relative() { + normalize_path(&canonical_path.join(raw_path)) + } else { + raw_path.to_owned() + } } else { canonical_path.clone() }; @@ -1960,6 +1964,116 @@ impl FakeFs { .unwrap(); } + pub async fn add_linked_worktree_for_repo( + &self, + dot_git: &Path, + emit_git_event: bool, + worktree: Worktree, + ) { + let ref_name = worktree + .ref_name + .as_ref() + .expect("linked worktree must have a ref_name"); + let branch_name = ref_name + .strip_prefix("refs/heads/") + .unwrap_or(ref_name.as_ref()); + + // Create ref in git state. + self.with_git_state(dot_git, false, |state| { + state + .refs + .insert(ref_name.to_string(), worktree.sha.to_string()); + }) + .unwrap(); + + // Create .git/worktrees// directory with HEAD, commondir, and gitdir. + let worktrees_entry_dir = dot_git.join("worktrees").join(branch_name); + self.create_dir(&worktrees_entry_dir).await.unwrap(); + + self.write_file_internal( + worktrees_entry_dir.join("HEAD"), + format!("ref: {ref_name}").into_bytes(), + false, + ) + .unwrap(); + + self.write_file_internal( + worktrees_entry_dir.join("commondir"), + dot_git.to_string_lossy().into_owned().into_bytes(), + false, + ) + .unwrap(); + + let worktree_dot_git = worktree.path.join(".git"); + self.write_file_internal( + worktrees_entry_dir.join("gitdir"), + worktree_dot_git.to_string_lossy().into_owned().into_bytes(), + false, + ) + .unwrap(); + + // Create the worktree checkout directory with a .git file pointing back. + self.create_dir(&worktree.path).await.unwrap(); + + self.write_file_internal( + &worktree_dot_git, + format!("gitdir: {}", worktrees_entry_dir.display()).into_bytes(), + false, + ) + .unwrap(); + + if emit_git_event { + self.with_git_state(dot_git, true, |_| {}).unwrap(); + } + } + + pub async fn remove_worktree_for_repo( + &self, + dot_git: &Path, + emit_git_event: bool, + ref_name: &str, + ) { + let branch_name = ref_name.strip_prefix("refs/heads/").unwrap_or(ref_name); + let worktrees_entry_dir = dot_git.join("worktrees").join(branch_name); + + // Read gitdir to find the worktree checkout path. + let gitdir_content = self + .load_internal(worktrees_entry_dir.join("gitdir")) + .await + .unwrap(); + let gitdir_str = String::from_utf8(gitdir_content).unwrap(); + let worktree_path = PathBuf::from(gitdir_str.trim()) + .parent() + .map(PathBuf::from) + .unwrap_or_default(); + + // Remove the worktree checkout directory. + self.remove_dir( + &worktree_path, + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await + .unwrap(); + + // Remove the .git/worktrees// directory. + self.remove_dir( + &worktrees_entry_dir, + RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + + if emit_git_event { + self.with_git_state(dot_git, true, |_| {}).unwrap(); + } + } + pub fn set_unmerged_paths_for_repo( &self, dot_git: &Path, diff --git a/crates/remote/src/transport/docker.rs b/crates/remote/src/transport/docker.rs index 2b935e50fa49054a2668a71d30818fdd2fb57b1d..eddfa1216927dffa88f63c00c2e373233b426e83 100644 --- a/crates/remote/src/transport/docker.rs +++ b/crates/remote/src/transport/docker.rs @@ -30,7 +30,7 @@ use crate::{ transport::parse_platform, }; -#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct DockerConnectionOptions { pub name: String, pub container_id: String, diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs index 0b8e56ac99565218dd827048afdee71e896f2667..9d06c7d31f1e1b34676db84a4f8e50131897f94d 100644 --- a/crates/sidebar/src/project_group_builder.rs +++ b/crates/sidebar/src/project_group_builder.rs @@ -250,15 +250,17 @@ mod tests { }), ) .await; - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { + fs.add_linked_worktree_for_repo( + std::path::Path::new("/project/.git"), + false, + git::repository::Worktree { path: std::path::PathBuf::from("/wt/feature-a"), ref_name: Some("refs/heads/feature-a".into()), sha: "abc".into(), is_main: false, - }); - }) - .expect("git state should be set"); + }, + ) + .await; fs } diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index b9bd873d369a44d3e09db9771383c111ead2ccb6..72f0bbdc18aaeead26de62164fa64ebf3bb64e8d 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -12,7 +12,10 @@ use gpui::TestAppContext; use pretty_assertions::assert_eq; use project::AgentId; use settings::SettingsStore; -use std::{path::PathBuf, sync::Arc}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use util::path_list::PathList; fn init_test(cx: &mut TestAppContext) { @@ -2435,38 +2438,24 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp fs.insert_tree( "/project", serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, + ".git": {}, "src": {}, }), ) .await; // Worktree checkout pointing back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), ref_name: Some("refs/heads/feature-a".into()), sha: "aaa".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; cx.update(|cx| ::set_global(fs.clone(), cx)); @@ -2573,15 +2562,17 @@ async fn test_search_matches_worktree_name(cx: &mut TestAppContext) { let (project, fs) = init_test_project_with_git("/project", cx).await; fs.as_fake() - .with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { + .add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { path: std::path::PathBuf::from("/wt/rosewood"), ref_name: Some("refs/heads/rosewood".into()), sha: "abc".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; project .update(cx, |project, cx| project.git_scans_complete(cx)) @@ -2635,15 +2626,17 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { // Now add the worktree to the git state and trigger a rescan. fs.as_fake() - .with_git_state(std::path::Path::new("/project/.git"), true, |state| { - state.worktrees.push(git::repository::Worktree { + .add_linked_worktree_for_repo( + Path::new("/project/.git"), + true, + git::repository::Worktree { path: std::path::PathBuf::from("/wt/rosewood"), ref_name: Some("refs/heads/rosewood".into()), sha: "abc".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; cx.run_until_parked(); @@ -2667,16 +2660,6 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC "/project", serde_json::json!({ ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - "feature-b": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-b", - }, - }, }, "src": {}, }), @@ -2684,20 +2667,26 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC .await; // Two worktree checkouts whose .git files point back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + }, ) .await; - fs.insert_tree( - "/wt-feature-b", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-b", - "src": {}, - }), + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-b"), + ref_name: Some("refs/heads/feature-b".into()), + sha: "bbb".into(), + is_main: false, + }, ) .await; @@ -2735,24 +2724,6 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC ] ); - // Configure the main repo to list both worktrees before opening - // it so the initial git scan picks them up. - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - is_main: false, - }); - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-b"), - ref_name: Some("refs/heads/feature-b".into()), - sha: "bbb".into(), - is_main: false, - }); - }) - .unwrap(); - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; main_project .update(cx, |p, cx| p.git_scans_complete(cx)) @@ -2788,54 +2759,33 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut fs.insert_tree( "/project", serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - "feature-b": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-b", - }, - }, - }, - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/wt-feature-b", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-b", + ".git": {}, "src": {}, }), ) .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), ref_name: Some("refs/heads/feature-a".into()), sha: "aaa".into(), is_main: false, - }); - state.worktrees.push(git::repository::Worktree { + }, + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-b"), ref_name: Some("refs/heads/feature-b".into()), sha: "bbb".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; cx.update(|cx| ::set_global(fs.clone(), cx)); @@ -2884,18 +2834,7 @@ async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext fs.insert_tree( "/project_a", serde_json::json!({ - ".git": { - "worktrees": { - "olivetti": { - "commondir": "../../", - "HEAD": "ref: refs/heads/olivetti", - }, - "selectric": { - "commondir": "../../", - "HEAD": "ref: refs/heads/selectric", - }, - }, - }, + ".git": {}, "src": {}, }), ) @@ -2903,56 +2842,28 @@ async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext fs.insert_tree( "/project_b", serde_json::json!({ - ".git": { - "worktrees": { - "olivetti": { - "commondir": "../../", - "HEAD": "ref: refs/heads/olivetti", - }, - "selectric": { - "commondir": "../../", - "HEAD": "ref: refs/heads/selectric", - }, - }, - }, + ".git": {}, "src": {}, }), ) .await; // Worktree checkouts. - for (repo, branch) in &[ - ("project_a", "olivetti"), - ("project_a", "selectric"), - ("project_b", "olivetti"), - ("project_b", "selectric"), - ] { - let worktree_path = format!("/worktrees/{repo}/{branch}/{repo}"); - let gitdir = format!("gitdir: /{repo}/.git/worktrees/{branch}"); - fs.insert_tree( - &worktree_path, - serde_json::json!({ - ".git": gitdir, - "src": {}, - }), - ) - .await; - } - - // Register linked worktrees. for repo in &["project_a", "project_b"] { let git_path = format!("/{repo}/.git"); - fs.with_git_state(std::path::Path::new(&git_path), false, |state| { - for branch in &["olivetti", "selectric"] { - state.worktrees.push(git::repository::Worktree { + for branch in &["olivetti", "selectric"] { + fs.add_linked_worktree_for_repo( + Path::new(&git_path), + false, + git::repository::Worktree { path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")), ref_name: Some(format!("refs/heads/{branch}").into()), sha: "aaa".into(), is_main: false, - }); - } - }) - .unwrap(); + }, + ) + .await; + } } cx.update(|cx| ::set_global(fs.clone(), cx)); @@ -3005,14 +2916,7 @@ async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext fs.insert_tree( "/project_a", serde_json::json!({ - ".git": { - "worktrees": { - "olivetti": { - "commondir": "../../", - "HEAD": "ref: refs/heads/olivetti", - }, - }, - }, + ".git": {}, "src": {}, }), ) @@ -3020,41 +2924,25 @@ async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext fs.insert_tree( "/project_b", serde_json::json!({ - ".git": { - "worktrees": { - "olivetti": { - "commondir": "../../", - "HEAD": "ref: refs/heads/olivetti", - }, - }, - }, + ".git": {}, "src": {}, }), ) .await; for repo in &["project_a", "project_b"] { - let worktree_path = format!("/worktrees/{repo}/olivetti/{repo}"); - let gitdir = format!("gitdir: /{repo}/.git/worktrees/olivetti"); - fs.insert_tree( - &worktree_path, - serde_json::json!({ - ".git": gitdir, - "src": {}, - }), - ) - .await; - let git_path = format!("/{repo}/.git"); - fs.with_git_state(std::path::Path::new(&git_path), false, |state| { - state.worktrees.push(git::repository::Worktree { + fs.add_linked_worktree_for_repo( + Path::new(&git_path), + false, + git::repository::Worktree { path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")), ref_name: Some("refs/heads/olivetti".into()), sha: "aaa".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; } cx.update(|cx| ::set_global(fs.clone(), cx)); @@ -3114,38 +3002,24 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp fs.insert_tree( "/project", serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, + ".git": {}, "src": {}, }), ) .await; // Worktree checkout pointing back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), ref_name: Some("refs/heads/feature-a".into()), sha: "aaa".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; cx.update(|cx| ::set_global(fs.clone(), cx)); @@ -3231,37 +3105,23 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp fs.insert_tree( "/project", serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", + ".git": {}, "src": {}, }), ) .await; - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), ref_name: Some("refs/heads/feature-a".into()), sha: "aaa".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; cx.update(|cx| ::set_global(fs.clone(), cx)); @@ -3338,37 +3198,23 @@ async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut fs.insert_tree( "/project", serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", + ".git": {}, "src": {}, }), ) .await; - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), ref_name: Some("refs/heads/feature-a".into()), sha: "aaa".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; cx.update(|cx| ::set_global(fs.clone(), cx)); @@ -3444,37 +3290,23 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje fs.insert_tree( "/project", serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", + ".git": {}, "src": {}, }), ) .await; - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), ref_name: Some("refs/heads/feature-a".into()), sha: "aaa".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; cx.update(|cx| ::set_global(fs.clone(), cx)); @@ -3595,37 +3427,23 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( fs.insert_tree( "/project", serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", + ".git": {}, "src": {}, }), ) .await; - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), ref_name: Some("refs/heads/feature-a".into()), sha: "aaa".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; cx.update(|cx| ::set_global(fs.clone(), cx)); @@ -4190,37 +4008,23 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon fs.insert_tree( "/project", serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", + ".git": {}, "src": {}, }), ) .await; - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), ref_name: Some("refs/heads/feature-a".into()), sha: "aaa".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; cx.update(|cx| ::set_global(fs.clone(), cx)); @@ -4354,22 +4158,7 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test fs.insert_tree( "/project", serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", + ".git": {}, "src": {}, }), ) @@ -4384,15 +4173,17 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test .await; // Register the linked worktree in the main repo. - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), ref_name: Some("refs/heads/feature-a".into()), sha: "aaa".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; cx.update(|cx| ::set_global(fs.clone(), cx)); @@ -5191,15 +4982,17 @@ mod property_test { let worktree_pathbuf = std::path::PathBuf::from(&worktree_path); state .fs - .with_git_state(dot_git_path, false, |git_state| { - git_state.worktrees.push(git::repository::Worktree { + .add_linked_worktree_for_repo( + dot_git_path, + false, + git::repository::Worktree { path: worktree_pathbuf, ref_name: Some(format!("refs/heads/{}", worktree_name).into()), sha: "aaa".into(), is_main: false, - }); - }) - .unwrap(); + }, + ) + .await; // Re-scan the main workspace's project so it discovers the new worktree. let main_workspace =