diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 4fc6e3dd1f257377e3f5213b1ae216115fd01fff..f9a136c10fe26ce1763fbde52c532f065e097463 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2642,6 +2642,12 @@ impl AgentPanel { } } + // TODO: The mapping from workspace root paths to git repositories needs a + // unified approach across the codebase: this method, `sidebar::is_root_repo`, + // thread persistence (which PathList is saved to the database), and thread + // querying (which PathList is used to read threads back). All of these need + // to agree on how repos are resolved for a given workspace, especially in + // multi-root and nested-repo configurations. /// Partitions the project's visible worktrees into git-backed repositories /// and plain (non-git) paths. Git repos will have worktrees created for /// them; non-git paths are carried over to the new workspace as-is. diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 2d4259717d160521ddd4884cbb6a1a1241456b64..24c5d5f5e5295a7e25af9f486323a16a2405c8e0 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -18,6 +18,8 @@ use project::Event as ProjectEvent; use settings::Settings; use std::collections::{HashMap, HashSet}; use std::mem; +use std::path::Path; +use std::sync::Arc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem, @@ -107,6 +109,8 @@ struct ThreadEntry { is_live: bool, is_background: bool, highlight_positions: Vec, + worktree_name: Option, + worktree_highlight_positions: Vec, diff_stats: DiffStats, } @@ -172,6 +176,32 @@ fn fuzzy_match_positions(query: &str, candidate: &str) -> Option> { } } +// TODO: The mapping from workspace root paths to git repositories needs a +// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`, +// thread persistence (which PathList is saved to the database), and thread +// querying (which PathList is used to read threads back). All of these need +// to agree on how repos are resolved for a given workspace, especially in +// multi-root and nested-repo configurations. +fn root_repository_snapshots( + workspace: &Entity, + cx: &App, +) -> Vec { + let (path_list, _) = workspace_path_list_and_label(workspace, cx); + let project = workspace.read(cx).project().read(cx); + project + .repositories(cx) + .values() + .filter_map(|repo| { + let snapshot = repo.read(cx).snapshot(); + let is_root = path_list + .paths() + .iter() + .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref()); + is_root.then_some(snapshot) + }) + .collect() +} + fn workspace_path_list_and_label( workspace: &Entity, cx: &App, @@ -348,6 +378,26 @@ impl Sidebar { ) .detach(); + let git_store = workspace.read(cx).project().read(cx).git_store().clone(); + cx.subscribe_in( + &git_store, + window, + |this, _, event: &project::git_store::GitStoreEvent, window, cx| { + if matches!( + event, + project::git_store::GitStoreEvent::RepositoryUpdated( + _, + project::git_store::RepositoryEvent::GitWorktreeListChanged, + _, + ) + ) { + this.prune_stale_worktree_workspaces(window, cx); + this.update_entries(cx); + } + }, + ) + .detach(); + cx.subscribe_in( workspace, window, @@ -472,7 +522,52 @@ impl Sidebar { // Compute active_entry_index inline during the build pass. let mut active_entry_index: Option = None; - for workspace in workspaces.iter() { + // Identify absorbed workspaces in a single pass. A workspace is + // "absorbed" when it points at a git worktree checkout whose main + // repo is open as another workspace — its threads appear under the + // main repo's header instead of getting their own. + let mut main_repo_workspace: HashMap, usize> = HashMap::new(); + let mut absorbed: HashMap = HashMap::new(); + let mut pending: HashMap, Vec<(usize, SharedString)>> = HashMap::new(); + + for (i, workspace) in workspaces.iter().enumerate() { + for snapshot in root_repository_snapshots(workspace, cx) { + if snapshot.work_directory_abs_path == snapshot.original_repo_abs_path { + main_repo_workspace + .entry(snapshot.work_directory_abs_path.clone()) + .or_insert(i); + if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) { + for (ws_idx, name) in waiting { + absorbed.insert(ws_idx, (i, name)); + } + } + } else { + let name: SharedString = snapshot + .work_directory_abs_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + .into(); + if let Some(&main_idx) = + main_repo_workspace.get(&snapshot.original_repo_abs_path) + { + absorbed.insert(i, (main_idx, name)); + } else { + pending + .entry(snapshot.original_repo_abs_path.clone()) + .or_default() + .push((i, name)); + } + } + } + } + + for (ws_index, workspace) in workspaces.iter().enumerate() { + if absorbed.contains_key(&ws_index) { + continue; + } + let (path_list, label) = workspace_path_list_and_label(workspace, cx); let is_collapsed = self.collapsed_groups.contains(&path_list); @@ -481,8 +576,11 @@ impl Sidebar { let mut threads: Vec = Vec::new(); if should_load_threads { + let mut seen_session_ids: HashSet = HashSet::new(); + if let Some(ref thread_store) = thread_store { for meta in thread_store.read(cx).threads_for_paths(&path_list) { + seen_session_ids.insert(meta.id.clone()); threads.push(ThreadEntry { session_info: meta.into(), icon: IconName::ZedAgent, @@ -492,11 +590,56 @@ impl Sidebar { is_live: false, is_background: false, highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }); } } + // Load threads from linked git worktrees of this workspace's repos. + if let Some(ref thread_store) = thread_store { + let mut linked_worktree_queries: Vec<(PathList, SharedString)> = Vec::new(); + for snapshot in root_repository_snapshots(workspace, cx) { + if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path { + continue; + } + for git_worktree in snapshot.linked_worktrees() { + let name = git_worktree + .path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + linked_worktree_queries.push(( + PathList::new(std::slice::from_ref(&git_worktree.path)), + name.into(), + )); + } + } + + for (worktree_path_list, worktree_name) in &linked_worktree_queries { + for meta in thread_store.read(cx).threads_for_paths(worktree_path_list) { + if !seen_session_ids.insert(meta.id.clone()) { + continue; + } + threads.push(ThreadEntry { + session_info: meta.into(), + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::default(), + workspace: workspace.clone(), + is_live: false, + is_background: false, + highlight_positions: Vec::new(), + worktree_name: Some(worktree_name.clone()), + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }); + } + } + } + let live_infos = Self::all_thread_infos_for_workspace(workspace, cx); if !live_infos.is_empty() { @@ -570,7 +713,16 @@ impl Sidebar { if let Some(positions) = fuzzy_match_positions(&query, title) { thread.highlight_positions = positions; } - if workspace_matched || !thread.highlight_positions.is_empty() { + if let Some(worktree_name) = &thread.worktree_name { + if let Some(positions) = fuzzy_match_positions(&query, worktree_name) { + thread.worktree_highlight_positions = positions; + } + } + let worktree_matched = !thread.worktree_highlight_positions.is_empty(); + if workspace_matched + || !thread.highlight_positions.is_empty() + || worktree_matched + { matched_threads.push(thread); } } @@ -1024,6 +1176,52 @@ impl Sidebar { }); } + fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + + // Collect all worktree paths that are currently listed by any main + // repo open in any workspace. + let mut known_worktree_paths: HashSet = HashSet::new(); + for workspace in &workspaces { + for snapshot in root_repository_snapshots(workspace, cx) { + if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path { + continue; + } + for git_worktree in snapshot.linked_worktrees() { + known_worktree_paths.insert(git_worktree.path.to_path_buf()); + } + } + } + + // Find workspaces that consist of exactly one root folder which is a + // stale worktree checkout. Multi-root workspaces are never pruned — + // losing one worktree shouldn't destroy a workspace that also + // contains other folders. + let mut to_remove: Vec> = Vec::new(); + for workspace in &workspaces { + let (path_list, _) = workspace_path_list_and_label(workspace, cx); + if path_list.paths().len() != 1 { + continue; + } + let should_prune = root_repository_snapshots(workspace, cx) + .iter() + .any(|snapshot| { + snapshot.work_directory_abs_path != snapshot.original_repo_abs_path + && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref()) + }); + if should_prune { + to_remove.push(workspace.clone()); + } + } + + for workspace in &to_remove { + self.remove_workspace(workspace, window, cx); + } + } + fn remove_workspace( &mut self, workspace: &Entity, @@ -1316,6 +1514,10 @@ impl Sidebar { .when_some(thread.icon_from_external_svg.clone(), |this, svg| { this.custom_icon_from_external_svg(svg) }) + .when_some(thread.worktree_name.clone(), |this, name| { + this.worktree(name) + }) + .worktree_highlight_positions(thread.worktree_highlight_positions.clone()) .when_some(timestamp, |this, ts| this.timestamp(ts)) .highlight_positions(thread.highlight_positions.to_vec()) .status(thread.status) @@ -1913,9 +2115,14 @@ mod tests { } else { "" }; + let worktree = thread + .worktree_name + .as_ref() + .map(|name| format!(" {{{}}}", name)) + .unwrap_or_default(); format!( - " {}{}{}{}{}", - title, active, status_str, notified, selected + " {}{}{}{}{}{}", + title, worktree, active, status_str, notified, selected ) } ListEntry::ViewMore { @@ -2244,6 +2451,8 @@ mod tests { is_live: false, is_background: false, highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // Active thread with Running status @@ -2263,6 +2472,8 @@ mod tests { is_live: true, is_background: false, highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // Active thread with Error status @@ -2282,6 +2493,8 @@ mod tests { is_live: true, is_background: false, highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // Thread with WaitingForConfirmation status, not active @@ -2301,6 +2514,8 @@ mod tests { is_live: false, is_background: false, highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // Background thread that completed (should show notification) @@ -2320,6 +2535,8 @@ mod tests { is_live: true, is_background: true, highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // View More entry @@ -3829,4 +4046,263 @@ mod tests { ); }); } + + async fn save_named_thread( + session_id: &str, + title: &str, + path_list: &PathList, + cx: &mut gpui::VisualTestContext, + ) { + let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); + let save_task = thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new(Arc::from(session_id)), + make_test_thread( + title, + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + ), + path_list.clone(), + cx, + ) + }); + save_task.await.unwrap(); + cx.run_until_parked(); + } + + async fn init_test_project_with_git( + worktree_path: &str, + cx: &mut TestAppContext, + ) -> (Entity, Arc) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + worktree_path, + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await; + (project, fs) + } + + #[gpui::test] + 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 { + path: std::path::PathBuf::from("/wt/rosewood"), + ref_name: "refs/heads/rosewood".into(), + sha: "abc".into(), + }); + }) + .unwrap(); + + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); + save_named_thread("main-t", "Unrelated Thread", &main_paths, cx).await; + save_named_thread("wt-t", "Fix Bug", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Search for "rosewood" — should match the worktree name, not the title. + type_in_search(&sidebar, "rosewood", cx); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Fix Bug {rosewood} <== selected"], + ); + } + + #[gpui::test] + async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { + let (project, fs) = init_test_project_with_git("/project", cx).await; + + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread against a worktree path that doesn't exist yet. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); + save_named_thread("wt-thread", "Worktree Thread", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Thread is not visible yet — no worktree knows about this path. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " [+ New Thread]"] + ); + + // 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 { + path: std::path::PathBuf::from("/wt/rosewood"), + ref_name: "refs/heads/rosewood".into(), + sha: "abc".into(), + }); + }) + .unwrap(); + + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Worktree Thread {rosewood}",] + ); + } + + #[gpui::test] + async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Create the main repo directory (not opened as a workspace yet). + 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; + + // 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": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-b", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-b", + "src": {}, + }), + ) + .await; + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; + + project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await; + project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + // Open both worktrees as workspaces — no main repo yet. + let (multi_workspace, cx) = cx + .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); + save_named_thread("thread-a", "Thread A", &paths_a, cx).await; + save_named_thread("thread-b", "Thread B", &paths_b, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Without the main repo, each worktree has its own header. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [wt-feature-a]", + " Thread A", + "v [wt-feature-b]", + " Thread B", + ] + ); + + // 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: "refs/heads/feature-a".into(), + sha: "aaa".into(), + }); + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-b"), + ref_name: "refs/heads/feature-b".into(), + sha: "bbb".into(), + }); + }) + .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)) + .await; + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(main_project.clone(), window, cx); + }); + cx.run_until_parked(); + + // Both worktree workspaces should now be absorbed under the main + // repo header, with worktree chips. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Thread A {wt-feature-a}", + " Thread B {wt-feature-b}", + ] + ); + + // Remove feature-b from the main repo's linked worktrees. + // The feature-b workspace should be pruned automatically. + fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| { + state + .worktrees + .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b")); + }) + .unwrap(); + + cx.run_until_parked(); + + // feature-b's workspace is pruned; feature-a remains absorbed + // under the main repo. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Thread A {wt-feature-a}",] + ); + } } diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 3e4b5c2ce211f68ef7e12895b542db5e6e3ea47c..75d7dbf194068f78b3d566e54bb0fa18f66a9878 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -109,6 +109,7 @@ CREATE TABLE "project_repositories" ( "head_commit_details" VARCHAR, "remote_upstream_url" VARCHAR, "remote_origin_url" VARCHAR, + "linked_worktrees" VARCHAR, PRIMARY KEY (project_id, id) ); diff --git a/crates/collab/migrations/20251208000000_test_schema.sql b/crates/collab/migrations/20251208000000_test_schema.sql index 53543a23f710e49084a7b1127e7b743df6ef97c8..394deaf2c0d6a80a2ab6ab1b95a333081c816e23 100644 --- a/crates/collab/migrations/20251208000000_test_schema.sql +++ b/crates/collab/migrations/20251208000000_test_schema.sql @@ -307,7 +307,8 @@ CREATE TABLE public.project_repositories ( head_commit_details character varying, merge_message character varying, remote_upstream_url character varying, - remote_origin_url character varying + remote_origin_url character varying, + linked_worktrees text ); CREATE TABLE public.project_repository_statuses ( diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 24cf639a715aa9b88da80375b389debaea0c4295..71365fb3846c1dccbf527d76779ed8816bde243b 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -374,6 +374,9 @@ impl Database { merge_message: ActiveValue::set(update.merge_message.clone()), remote_upstream_url: ActiveValue::set(update.remote_upstream_url.clone()), remote_origin_url: ActiveValue::set(update.remote_origin_url.clone()), + linked_worktrees: ActiveValue::Set(Some( + serde_json::to_string(&update.linked_worktrees).unwrap(), + )), }) .on_conflict( OnConflict::columns([ @@ -388,6 +391,7 @@ impl Database { project_repository::Column::CurrentMergeConflicts, project_repository::Column::HeadCommitDetails, project_repository::Column::MergeMessage, + project_repository::Column::LinkedWorktrees, ]) .to_owned(), ) @@ -883,6 +887,11 @@ impl Database { remote_upstream_url: db_repository_entry.remote_upstream_url.clone(), remote_origin_url: db_repository_entry.remote_origin_url.clone(), original_repo_abs_path: Some(db_repository_entry.abs_path), + linked_worktrees: db_repository_entry + .linked_worktrees + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(), }); } } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index b4cbd83167b227542d8de1022b7e2cf49f5a7645..3197d142cba7a1969e6fdb9423dc94497f6ca53c 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -799,6 +799,11 @@ impl Database { remote_upstream_url: db_repository.remote_upstream_url.clone(), remote_origin_url: db_repository.remote_origin_url.clone(), original_repo_abs_path: Some(db_repository.abs_path), + linked_worktrees: db_repository + .linked_worktrees + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(), }); } } diff --git a/crates/collab/src/db/tables/project_repository.rs b/crates/collab/src/db/tables/project_repository.rs index 190ae8d79c54bb78daef4a1568ec75683eb0b0f2..33b20817e61a137285e27525eb5b2a221d3cfd9e 100644 --- a/crates/collab/src/db/tables/project_repository.rs +++ b/crates/collab/src/db/tables/project_repository.rs @@ -24,6 +24,8 @@ pub struct Model { pub head_commit_details: Option, pub remote_upstream_url: Option, pub remote_origin_url: Option, + // JSON array of linked worktree objects + pub linked_worktrees: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index f8c461b91fc41cc5a0e20271a85e685af2801d24..fc20150d662b96be9b6ad4f99ae1f33032b6fb7b 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -1,9 +1,10 @@ use std::path::{Path, PathBuf}; use call::ActiveCall; +use client::RECEIVE_TIMEOUT; use collections::HashMap; use git::{ - repository::RepoPath, + repository::{RepoPath, Worktree as GitWorktree}, status::{DiffStat, FileStatus, StatusCode, TrackedStatus}, }; use git_ui::{git_panel::GitPanel, project_diff::ProjectDiff}; @@ -365,6 +366,236 @@ async fn test_remote_git_worktrees( ); } +#[gpui::test] +async fn test_linked_worktrees_sync( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + // Set up a git repo with two linked worktrees already present. + client_a + .fs() + .insert_tree( + path!("/project"), + json!({ ".git": {}, "file.txt": "content" }), + ) + .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: "refs/heads/main".into(), + sha: "aaa111".into(), + }); + state.worktrees.push(GitWorktree { + path: PathBuf::from(path!("/project/feature-branch")), + ref_name: "refs/heads/feature-branch".into(), + sha: "bbb222".into(), + }); + state.worktrees.push(GitWorktree { + path: PathBuf::from(path!("/project/bugfix-branch")), + ref_name: "refs/heads/bugfix-branch".into(), + sha: "ccc333".into(), + }); + }) + .unwrap(); + + let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await; + + // Wait for git scanning to complete on the host. + executor.run_until_parked(); + + // Verify the host sees 2 linked worktrees (main worktree is filtered out). + let host_linked = project_a.read_with(cx_a, |project, cx| { + let repos = project.repositories(cx); + assert_eq!(repos.len(), 1, "host should have exactly 1 repository"); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + host_linked.len(), + 2, + "host should have 2 linked worktrees (main filtered out)" + ); + assert_eq!( + host_linked[0].path, + PathBuf::from(path!("/project/feature-branch")) + ); + assert_eq!( + host_linked[0].ref_name.as_ref(), + "refs/heads/feature-branch" + ); + assert_eq!(host_linked[0].sha.as_ref(), "bbb222"); + assert_eq!( + host_linked[1].path, + PathBuf::from(path!("/project/bugfix-branch")) + ); + assert_eq!(host_linked[1].ref_name.as_ref(), "refs/heads/bugfix-branch"); + assert_eq!(host_linked[1].sha.as_ref(), "ccc333"); + + // Share the project and have client B join. + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + executor.run_until_parked(); + + // Verify the guest sees the same linked worktrees as the host. + let guest_linked = project_b.read_with(cx_b, |project, cx| { + let repos = project.repositories(cx); + assert_eq!(repos.len(), 1, "guest should have exactly 1 repository"); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + guest_linked, host_linked, + "guest's linked_worktrees should match host's after initial 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")), + ref_name: "refs/heads/hotfix-branch".into(), + sha: "ddd444".into(), + }); + }) + .unwrap(); + + // Wait for the host to re-scan and propagate the update. + executor.run_until_parked(); + + // Verify host now sees 3 linked worktrees. + let host_linked_updated = project_a.read_with(cx_a, |project, cx| { + let repos = project.repositories(cx); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + host_linked_updated.len(), + 3, + "host should now have 3 linked worktrees" + ); + assert_eq!( + host_linked_updated[2].path, + PathBuf::from(path!("/project/hotfix-branch")) + ); + + // Verify the guest also received the update. + let guest_linked_updated = project_b.read_with(cx_b, |project, cx| { + let repos = project.repositories(cx); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + guest_linked_updated, host_linked_updated, + "guest's linked_worktrees should match host's after update" + ); + + // 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.as_ref() != "refs/heads/bugfix-branch"); + }) + .unwrap(); + + executor.run_until_parked(); + + // Verify host now sees 2 linked worktrees (feature-branch and hotfix-branch). + let host_linked_after_removal = project_a.read_with(cx_a, |project, cx| { + let repos = project.repositories(cx); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + host_linked_after_removal.len(), + 2, + "host should have 2 linked worktrees after removal" + ); + assert!( + host_linked_after_removal + .iter() + .all(|wt| wt.ref_name.as_ref() != "refs/heads/bugfix-branch"), + "bugfix-branch should have been removed" + ); + + // Verify the guest also reflects the removal. + let guest_linked_after_removal = project_b.read_with(cx_b, |project, cx| { + let repos = project.repositories(cx); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + guest_linked_after_removal, host_linked_after_removal, + "guest's linked_worktrees should match host's after removal" + ); + + // Test DB roundtrip: client C joins late, getting state from the database. + // This verifies that linked_worktrees are persisted and restored correctly. + let project_c = client_c.join_remote_project(project_id, cx_c).await; + executor.run_until_parked(); + + let late_joiner_linked = project_c.read_with(cx_c, |project, cx| { + let repos = project.repositories(cx); + assert_eq!( + repos.len(), + 1, + "late joiner should have exactly 1 repository" + ); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + late_joiner_linked, host_linked_after_removal, + "late-joining client's linked_worktrees should match host's (DB roundtrip)" + ); + + // Test reconnection: disconnect client B (guest) and reconnect. + // After rejoining, client B should get linked_worktrees back from the DB. + server.disconnect_client(client_b.peer_id().unwrap()); + executor.advance_clock(RECEIVE_TIMEOUT); + executor.run_until_parked(); + + // Client B reconnects automatically. + executor.advance_clock(RECEIVE_TIMEOUT); + executor.run_until_parked(); + + // Verify client B still has the correct linked worktrees after reconnection. + let guest_linked_after_reconnect = project_b.read_with(cx_b, |project, cx| { + let repos = project.repositories(cx); + assert_eq!( + repos.len(), + 1, + "guest should still have exactly 1 repository after reconnect" + ); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + guest_linked_after_reconnect, host_linked_after_removal, + "guest's linked_worktrees should survive guest disconnect/reconnect" + ); +} + #[gpui::test] async fn test_diff_stat_sync_between_host_and_downstream_client( cx_a: &mut TestAppContext, diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 85489b6057cd8214ee512fb477428c93cdb32219..0cb610f7dd2d4ccf809d907347bf3b3be2c82444 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -790,7 +790,7 @@ impl GitRepository for FakeGitRepository { } fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result> { - unimplemented!() + future::ready(Ok(String::new())).boxed() } fn diff_stat( diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 0572fd1f4f19beebd3674e1b24c828daffb9973c..e9330014c3f066705ac3ea1e54f5e498c5d22348 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -293,6 +293,7 @@ pub struct RepositorySnapshot { pub remote_origin_url: Option, pub remote_upstream_url: Option, pub stash_entries: GitStash, + pub linked_worktrees: Arc<[GitWorktree]>, } type JobId = u64; @@ -429,6 +430,7 @@ pub enum RepositoryEvent { StatusesChanged, BranchChanged, StashEntriesChanged, + GitWorktreeListChanged, PendingOpsChanged { pending_ops: SumTree }, GraphEvent((LogSource, LogOrder), GitGraphEvent), } @@ -3575,6 +3577,7 @@ impl RepositorySnapshot { remote_origin_url: None, remote_upstream_url: None, stash_entries: Default::default(), + linked_worktrees: Arc::from([]), path_style, } } @@ -3613,6 +3616,11 @@ impl RepositorySnapshot { original_repo_abs_path: Some( self.original_repo_abs_path.to_string_lossy().into_owned(), ), + linked_worktrees: self + .linked_worktrees + .iter() + .map(worktree_to_proto) + .collect(), } } @@ -3689,9 +3697,18 @@ impl RepositorySnapshot { original_repo_abs_path: Some( self.original_repo_abs_path.to_string_lossy().into_owned(), ), + linked_worktrees: self + .linked_worktrees + .iter() + .map(worktree_to_proto) + .collect(), } } + pub fn linked_worktrees(&self) -> &[GitWorktree] { + &self.linked_worktrees + } + pub fn status(&self) -> impl Iterator + '_ { self.statuses_by_path.iter().cloned() } @@ -6145,6 +6162,15 @@ impl Repository { cx.emit(RepositoryEvent::StashEntriesChanged) } self.snapshot.stash_entries = new_stash_entries; + let new_linked_worktrees: Arc<[GitWorktree]> = update + .linked_worktrees + .iter() + .map(proto_to_worktree) + .collect(); + if *self.snapshot.linked_worktrees != *new_linked_worktrees { + cx.emit(RepositoryEvent::GitWorktreeListChanged); + } + self.snapshot.linked_worktrees = new_linked_worktrees; self.snapshot.remote_upstream_url = update.remote_upstream_url; self.snapshot.remote_origin_url = update.remote_origin_url; @@ -6901,14 +6927,20 @@ async fn compute_snapshot( })) .boxed() }; - let (statuses, diff_stats) = futures::future::try_join( + let (statuses, diff_stats, all_worktrees) = futures::future::try_join3( backend.status(&[RepoPath::from_rel_path( &RelPath::new(".".as_ref(), PathStyle::local()).unwrap(), )]), diff_stat_future, + backend.worktrees(), ) .await?; + let linked_worktrees: Arc<[GitWorktree]> = all_worktrees + .into_iter() + .filter(|wt| wt.path != *work_directory_abs_path) + .collect(); + let diff_stat_map: HashMap<&RepoPath, DiffStat> = diff_stats.entries.iter().map(|(p, s)| (p, *s)).collect(); let stash_entries = backend.stash_entries().await?; @@ -6938,6 +6970,10 @@ async fn compute_snapshot( events.push(RepositoryEvent::BranchChanged); } + if *linked_worktrees != *prev_snapshot.linked_worktrees { + events.push(RepositoryEvent::GitWorktreeListChanged); + } + let remote_origin_url = backend.remote_url("origin").await; let remote_upstream_url = backend.remote_url("upstream").await; @@ -6954,6 +6990,7 @@ async fn compute_snapshot( remote_origin_url, remote_upstream_url, stash_entries, + linked_worktrees, }; Ok((snapshot, events)) diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 87fdc058f95c045de5f1e8f7ef03c8e32c2fa518..bb6b73ce3b89d51e9bf594c9e01254f5f0d579a4 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -126,6 +126,7 @@ message UpdateRepository { optional string remote_upstream_url = 14; optional string remote_origin_url = 15; optional string original_repo_abs_path = 16; + repeated Worktree linked_worktrees = 17; } message RemoveRepository {