Implement sidebar rendering of the configured worktrees (#51342)

Mikayla Maki created

Implements worktree support for the agent panel sidebar

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- N/A

Change summary

crates/agent_ui/src/agent_panel.rs                             |   6 
crates/agent_ui/src/sidebar.rs                                 | 484 +++
crates/collab/migrations.sqlite/20221109000000_test_schema.sql |   1 
crates/collab/migrations/20251208000000_test_schema.sql        |   3 
crates/collab/src/db/queries/projects.rs                       |   9 
crates/collab/src/db/queries/rooms.rs                          |   5 
crates/collab/src/db/tables/project_repository.rs              |   2 
crates/collab/tests/integration/git_tests.rs                   | 233 +
crates/fs/src/fake_git_repo.rs                                 |   2 
crates/project/src/git_store.rs                                |  39 
crates/proto/proto/git.proto                                   |   1 
11 files changed, 777 insertions(+), 8 deletions(-)

Detailed changes

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.

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<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}",]
+        );
+    }
 }

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 (

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(),
                     });
                 }
             }

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(),
                         });
                     }
                 }

crates/collab/src/db/tables/project_repository.rs 🔗

@@ -24,6 +24,8 @@ pub struct Model {
     pub head_commit_details: Option<String>,
     pub remote_upstream_url: Option<String>,
     pub remote_origin_url: Option<String>,
+    // JSON array of linked worktree objects
+    pub linked_worktrees: Option<String>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

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,

crates/fs/src/fake_git_repo.rs 🔗

@@ -790,7 +790,7 @@ impl GitRepository for FakeGitRepository {
     }
 
     fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result<String>> {
-        unimplemented!()
+        future::ready(Ok(String::new())).boxed()
     }
 
     fn diff_stat(

crates/project/src/git_store.rs 🔗

@@ -293,6 +293,7 @@ pub struct RepositorySnapshot {
     pub remote_origin_url: Option<String>,
     pub remote_upstream_url: Option<String>,
     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<PendingOps> },
     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<Item = StatusEntry> + '_ {
         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))

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 {