@@ -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<usize>,
+ worktree_name: Option<SharedString>,
+ worktree_highlight_positions: Vec<usize>,
diff_stats: DiffStats,
}
@@ -172,6 +176,32 @@ fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
}
}
+// 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<Workspace>,
+ cx: &App,
+) -> Vec<project::git_store::RepositorySnapshot> {
+ 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<Workspace>,
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<usize> = 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<Arc<Path>, usize> = HashMap::new();
+ let mut absorbed: HashMap<usize, (usize, SharedString)> = HashMap::new();
+ let mut pending: HashMap<Arc<Path>, 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<ThreadEntry> = Vec::new();
if should_load_threads {
+ let mut seen_session_ids: HashSet<acp::SessionId> = 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<Self>) {
+ 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<std::path::PathBuf> = 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<Entity<Workspace>> = 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<Workspace>,
@@ -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<project::Project>, Arc<dyn fs::Fs>) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ worktree_path,
+ serde_json::json!({
+ ".git": {},
+ "src": {},
+ }),
+ )
+ .await;
+ cx.update(|cx| <dyn fs::Fs>::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| <dyn fs::Fs>::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}",]
+ );
+ }
}
@@ -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,