Maintain root repo common dir path as a field on Worktree (#53023)

Max Brunsfeld and Eric Holk created

This enables us to always different git worktrees of the same repo
together.

Depends on https://github.com/zed-industries/cloud/pull/2220

Release Notes:

- N/A

---------

Co-authored-by: Eric Holk <eric@zed.dev>

Change summary

crates/collab/migrations.sqlite/20221109000000_test_schema.sql |  1 
crates/collab/migrations/20251208000000_test_schema.sql        |  3 
crates/collab/src/db.rs                                        |  2 
crates/collab/src/db/queries/projects.rs                       |  4 
crates/collab/src/db/queries/rooms.rs                          |  1 
crates/collab/src/db/tables/worktree.rs                        |  1 
crates/collab/src/rpc.rs                                       |  2 
crates/collab/tests/integration/git_tests.rs                   | 57 ++
crates/edit_prediction/src/license_detection.rs                |  1 
crates/project/src/lsp_store.rs                                |  3 
crates/project/src/manifest_tree.rs                            |  2 
crates/project/src/worktree_store.rs                           |  1 
crates/proto/proto/call.proto                                  |  1 
crates/proto/src/proto.rs                                      |  1 
crates/remote_server/src/remote_editing_tests.rs               | 82 +++
crates/worktree/src/worktree.rs                                | 48 ++
crates/worktree/tests/integration/main.rs                      | 91 ++++
17 files changed, 297 insertions(+), 4 deletions(-)

Detailed changes

crates/collab/migrations/20251208000000_test_schema.sql 🔗

@@ -484,7 +484,8 @@ CREATE TABLE public.worktrees (
     visible boolean NOT NULL,
     scan_id bigint NOT NULL,
     is_complete boolean DEFAULT false NOT NULL,
-    completed_scan_id bigint
+    completed_scan_id bigint,
+    root_repo_common_dir character varying
 );
 
 ALTER TABLE ONLY public.breakpoints ALTER COLUMN id SET DEFAULT nextval('public.breakpoints_id_seq'::regclass);

crates/collab/src/db.rs 🔗

@@ -559,6 +559,7 @@ pub struct RejoinedWorktree {
     pub settings_files: Vec<WorktreeSettingsFile>,
     pub scan_id: u64,
     pub completed_scan_id: u64,
+    pub root_repo_common_dir: Option<String>,
 }
 
 pub struct LeftRoom {
@@ -638,6 +639,7 @@ pub struct Worktree {
     pub settings_files: Vec<WorktreeSettingsFile>,
     pub scan_id: u64,
     pub completed_scan_id: u64,
+    pub root_repo_common_dir: Option<String>,
 }
 
 #[derive(Debug)]

crates/collab/src/db/queries/projects.rs 🔗

@@ -87,6 +87,7 @@ impl Database {
                         visible: ActiveValue::set(worktree.visible),
                         scan_id: ActiveValue::set(0),
                         completed_scan_id: ActiveValue::set(0),
+                        root_repo_common_dir: ActiveValue::set(None),
                     }
                 }))
                 .exec(&*tx)
@@ -203,6 +204,7 @@ impl Database {
                 visible: ActiveValue::set(worktree.visible),
                 scan_id: ActiveValue::set(0),
                 completed_scan_id: ActiveValue::set(0),
+                root_repo_common_dir: ActiveValue::set(None),
             }))
             .on_conflict(
                 OnConflict::columns([worktree::Column::ProjectId, worktree::Column::Id])
@@ -266,6 +268,7 @@ impl Database {
                     ActiveValue::default()
                 },
                 abs_path: ActiveValue::set(update.abs_path.clone()),
+                root_repo_common_dir: ActiveValue::set(update.root_repo_common_dir.clone()),
                 ..Default::default()
             })
             .exec(&*tx)
@@ -761,6 +764,7 @@ impl Database {
                         settings_files: Default::default(),
                         scan_id: db_worktree.scan_id as u64,
                         completed_scan_id: db_worktree.completed_scan_id as u64,
+                        root_repo_common_dir: db_worktree.root_repo_common_dir,
                         legacy_repository_entries: Default::default(),
                     },
                 )

crates/collab/src/db/queries/rooms.rs 🔗

@@ -629,6 +629,7 @@ impl Database {
                 settings_files: Default::default(),
                 scan_id: db_worktree.scan_id as u64,
                 completed_scan_id: db_worktree.completed_scan_id as u64,
+                root_repo_common_dir: db_worktree.root_repo_common_dir,
             };
 
             let rejoined_worktree = rejoined_project

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

@@ -15,6 +15,7 @@ pub struct Model {
     pub scan_id: i64,
     /// The last scan that fully completed.
     pub completed_scan_id: i64,
+    pub root_repo_common_dir: Option<String>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

crates/collab/src/rpc.rs 🔗

@@ -1485,6 +1485,7 @@ fn notify_rejoined_projects(
                 worktree_id: worktree.id,
                 abs_path: worktree.abs_path.clone(),
                 root_name: worktree.root_name,
+                root_repo_common_dir: worktree.root_repo_common_dir,
                 updated_entries: worktree.updated_entries,
                 removed_entries: worktree.removed_entries,
                 scan_id: worktree.scan_id,
@@ -1943,6 +1944,7 @@ async fn join_project(
             worktree_id,
             abs_path: worktree.abs_path.clone(),
             root_name: worktree.root_name,
+            root_repo_common_dir: worktree.root_repo_common_dir,
             updated_entries: worktree.entries,
             removed_entries: Default::default(),
             scan_id: worktree.scan_id,

crates/collab/tests/integration/git_tests.rs 🔗

@@ -1,4 +1,4 @@
-use std::path::{Path, PathBuf};
+use std::path::{self, Path, PathBuf};
 
 use call::ActiveCall;
 use client::RECEIVE_TIMEOUT;
@@ -17,6 +17,61 @@ use workspace::{MultiWorkspace, Workspace};
 
 use crate::TestServer;
 
+#[gpui::test]
+async fn test_root_repo_common_dir_sync(
+    executor: BackgroundExecutor,
+    cx_a: &mut TestAppContext,
+    cx_b: &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;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+
+    // Set up a project whose root IS a git repository.
+    client_a
+        .fs()
+        .insert_tree(
+            path!("/project"),
+            json!({ ".git": {}, "file.txt": "content" }),
+        )
+        .await;
+
+    let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
+    executor.run_until_parked();
+
+    // Host should see root_repo_common_dir pointing to .git at the root.
+    let host_common_dir = project_a.read_with(cx_a, |project, cx| {
+        let worktree = project.worktrees(cx).next().unwrap();
+        worktree.read(cx).snapshot().root_repo_common_dir().cloned()
+    });
+    assert_eq!(
+        host_common_dir.as_deref(),
+        Some(path::Path::new(path!("/project/.git"))),
+    );
+
+    // 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();
+
+    // Guest should see the same root_repo_common_dir as the host.
+    let guest_common_dir = project_b.read_with(cx_b, |project, cx| {
+        let worktree = project.worktrees(cx).next().unwrap();
+        worktree.read(cx).snapshot().root_repo_common_dir().cloned()
+    });
+    assert_eq!(
+        guest_common_dir, host_common_dir,
+        "guest should see the same root_repo_common_dir as host",
+    );
+}
+
 fn collect_diff_stats<C: gpui::AppContext>(
     panel: &gpui::Entity<GitPanel>,
     cx: &C,

crates/edit_prediction/src/license_detection.rs 🔗

@@ -319,6 +319,7 @@ impl LicenseDetectionWatcher {
                 }
                 worktree::Event::DeletedEntry(_)
                 | worktree::Event::UpdatedGitRepositories(_)
+                | worktree::Event::UpdatedRootRepoCommonDir
                 | worktree::Event::Deleted => {}
             });
 

crates/project/src/lsp_store.rs 🔗

@@ -4414,7 +4414,8 @@ impl LspStore {
                     }
                     worktree::Event::UpdatedGitRepositories(_)
                     | worktree::Event::DeletedEntry(_)
-                    | worktree::Event::Deleted => {}
+                    | worktree::Event::Deleted
+                    | worktree::Event::UpdatedRootRepoCommonDir => {}
                 })
                 .detach()
             }

crates/project/src/manifest_tree.rs 🔗

@@ -59,7 +59,7 @@ impl WorktreeRoots {
                         let path = TriePath::from(entry.path.as_ref());
                         this.roots.remove(&path);
                     }
-                    WorktreeEvent::Deleted => {}
+                    WorktreeEvent::Deleted | WorktreeEvent::UpdatedRootRepoCommonDir => {}
                 }
             }),
         })

crates/project/src/worktree_store.rs 🔗

@@ -812,6 +812,7 @@ impl WorktreeStore {
                     // The worktree root itself has been deleted (for single-file worktrees)
                     // The worktree will be removed via the observe_release callback
                 }
+                worktree::Event::UpdatedRootRepoCommonDir => {}
             }
         })
         .detach();

crates/proto/proto/call.proto 🔗

@@ -225,6 +225,7 @@ message UpdateWorktree {
   uint64 scan_id = 8;
   bool is_last_update = 9;
   string abs_path = 10;
+  optional string root_repo_common_dir = 11;
 }
 
 // deprecated

crates/proto/src/proto.rs 🔗

@@ -881,6 +881,7 @@ pub fn split_worktree_update(mut message: UpdateWorktree) -> impl Iterator<Item
             worktree_id: message.worktree_id,
             root_name: message.root_name.clone(),
             abs_path: message.abs_path.clone(),
+            root_repo_common_dir: message.root_repo_common_dir.clone(),
             updated_entries,
             removed_entries,
             scan_id: message.scan_id,

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -11,6 +11,7 @@ use languages::rust_lang;
 
 use extension::ExtensionHostProxy;
 use fs::{FakeFs, Fs};
+use git::repository::Worktree as GitWorktree;
 use gpui::{AppContext as _, Entity, SharedString, TestAppContext};
 use http_client::{BlockedHttpClient, FakeHttpClient};
 use language::{
@@ -1539,6 +1540,87 @@ async fn test_copy_file_into_remote_project(
     );
 }
 
+#[gpui::test]
+async fn test_remote_root_repo_common_dir(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        "/code",
+        json!({
+            "main_repo": {
+                ".git": {},
+                "file.txt": "content",
+            },
+            "no_git": {
+                "file.txt": "content",
+            },
+        }),
+    )
+    .await;
+
+    // Create a linked worktree that points back to main_repo's .git.
+    fs.add_linked_worktree_for_repo(
+        Path::new("/code/main_repo/.git"),
+        false,
+        GitWorktree {
+            path: PathBuf::from("/code/linked_worktree"),
+            ref_name: Some("refs/heads/feature-branch".into()),
+            sha: "abc123".into(),
+            is_main: false,
+        },
+    )
+    .await;
+
+    let (project, _headless) = init_test(&fs, cx, server_cx).await;
+
+    // Main repo: root_repo_common_dir should be the .git directory itself.
+    let (worktree_main, _) = project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree("/code/main_repo", true, cx)
+        })
+        .await
+        .unwrap();
+    cx.executor().run_until_parked();
+
+    let common_dir = worktree_main.read_with(cx, |worktree, _| {
+        worktree.snapshot().root_repo_common_dir().cloned()
+    });
+    assert_eq!(
+        common_dir.as_deref(),
+        Some(Path::new("/code/main_repo/.git")),
+    );
+
+    // Linked worktree: root_repo_common_dir should point to the main repo's .git.
+    let (worktree_linked, _) = project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree("/code/linked_worktree", true, cx)
+        })
+        .await
+        .unwrap();
+    cx.executor().run_until_parked();
+
+    let common_dir = worktree_linked.read_with(cx, |worktree, _| {
+        worktree.snapshot().root_repo_common_dir().cloned()
+    });
+    assert_eq!(
+        common_dir.as_deref(),
+        Some(Path::new("/code/main_repo/.git")),
+    );
+
+    // No git repo: root_repo_common_dir should be None.
+    let (worktree_no_git, _) = project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree("/code/no_git", true, cx)
+        })
+        .await
+        .unwrap();
+    cx.executor().run_until_parked();
+
+    let common_dir = worktree_no_git.read_with(cx, |worktree, _| {
+        worktree.snapshot().root_repo_common_dir().cloned()
+    });
+    assert_eq!(common_dir, None);
+}
+
 #[gpui::test]
 async fn test_remote_git_diffs(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
     let text_2 = "

crates/worktree/src/worktree.rs 🔗

@@ -176,6 +176,7 @@ pub struct Snapshot {
     root_char_bag: CharBag,
     entries_by_path: SumTree<Entry>,
     entries_by_id: SumTree<PathEntry>,
+    root_repo_common_dir: Option<Arc<SanitizedPath>>,
     always_included_entries: Vec<Arc<RelPath>>,
 
     /// A number that increases every time the worktree begins scanning
@@ -368,6 +369,7 @@ struct UpdateObservationState {
 pub enum Event {
     UpdatedEntries(UpdatedEntriesSet),
     UpdatedGitRepositories(UpdatedGitRepositoriesSet),
+    UpdatedRootRepoCommonDir,
     DeletedEntry(ProjectEntryId),
     /// The worktree root itself has been deleted (for single-file worktrees)
     Deleted,
@@ -407,6 +409,10 @@ impl Worktree {
             None
         };
 
+        let root_repo_common_dir = discover_root_repo_common_dir(&abs_path, fs.as_ref())
+            .await
+            .map(SanitizedPath::from_arc);
+
         Ok(cx.new(move |cx: &mut Context<Worktree>| {
             let mut snapshot = LocalSnapshot {
                 ignores_by_parent_abs_path: Default::default(),
@@ -426,6 +432,7 @@ impl Worktree {
                 ),
                 root_file_handle,
             };
+            snapshot.root_repo_common_dir = root_repo_common_dir;
 
             let worktree_id = snapshot.id();
             let settings_location = Some(SettingsLocation {
@@ -564,6 +571,7 @@ impl Worktree {
                     this.update(cx, |this, cx| {
                         let mut entries_changed = false;
                         let this = this.as_remote_mut().unwrap();
+                        let old_root_repo_common_dir = this.snapshot.root_repo_common_dir.clone();
                         {
                             let mut lock = this.background_snapshot.lock();
                             this.snapshot = lock.0.clone();
@@ -579,6 +587,9 @@ impl Worktree {
                         if entries_changed {
                             cx.emit(Event::UpdatedEntries(Arc::default()));
                         }
+                        if this.snapshot.root_repo_common_dir != old_root_repo_common_dir {
+                            cx.emit(Event::UpdatedRootRepoCommonDir);
+                        }
                         cx.notify();
                         while let Some((scan_id, _)) = this.snapshot_subscriptions.front() {
                             if this.observed_snapshot(*scan_id) {
@@ -1183,6 +1194,13 @@ impl LocalWorktree {
         cx: &mut Context<Worktree>,
     ) {
         let repo_changes = self.changed_repos(&self.snapshot, &mut new_snapshot);
+
+        new_snapshot.root_repo_common_dir = new_snapshot
+            .local_repo_for_work_directory_path(RelPath::empty())
+            .map(|repo| SanitizedPath::from_arc(repo.common_dir_abs_path.clone()));
+
+        let root_repo_common_dir_changed =
+            self.snapshot.root_repo_common_dir != new_snapshot.root_repo_common_dir;
         self.snapshot = new_snapshot;
 
         if let Some(share) = self.update_observer.as_mut() {
@@ -1198,6 +1216,9 @@ impl LocalWorktree {
         if !repo_changes.is_empty() {
             cx.emit(Event::UpdatedGitRepositories(repo_changes));
         }
+        if root_repo_common_dir_changed {
+            cx.emit(Event::UpdatedRootRepoCommonDir);
+        }
 
         while let Some((scan_id, _)) = self.snapshot_subscriptions.front() {
             if self.snapshot.completed_scan_id >= *scan_id {
@@ -2216,6 +2237,7 @@ impl Snapshot {
             always_included_entries: Default::default(),
             entries_by_path: Default::default(),
             entries_by_id: Default::default(),
+            root_repo_common_dir: None,
             scan_id: 1,
             completed_scan_id: 0,
         }
@@ -2241,6 +2263,12 @@ impl Snapshot {
         SanitizedPath::cast_arc_ref(&self.abs_path)
     }
 
+    pub fn root_repo_common_dir(&self) -> Option<&Arc<Path>> {
+        self.root_repo_common_dir
+            .as_ref()
+            .map(SanitizedPath::cast_arc_ref)
+    }
+
     fn build_initial_update(&self, project_id: u64, worktree_id: u64) -> proto::UpdateWorktree {
         let mut updated_entries = self
             .entries_by_path
@@ -2254,6 +2282,9 @@ impl Snapshot {
             worktree_id,
             abs_path: self.abs_path().to_string_lossy().into_owned(),
             root_name: self.root_name().to_proto(),
+            root_repo_common_dir: self
+                .root_repo_common_dir()
+                .map(|p| p.to_string_lossy().into_owned()),
             updated_entries,
             removed_entries: Vec::new(),
             scan_id: self.scan_id as u64,
@@ -2399,6 +2430,10 @@ impl Snapshot {
         self.entries_by_path.edit(entries_by_path_edits, ());
         self.entries_by_id.edit(entries_by_id_edits, ());
 
+        self.root_repo_common_dir = update
+            .root_repo_common_dir
+            .map(|p| SanitizedPath::new_arc(Path::new(&p)));
+
         self.scan_id = update.scan_id as usize;
         if update.is_last_update {
             self.completed_scan_id = update.scan_id as usize;
@@ -2627,6 +2662,9 @@ impl LocalSnapshot {
             worktree_id,
             abs_path: self.abs_path().to_string_lossy().into_owned(),
             root_name: self.root_name().to_proto(),
+            root_repo_common_dir: self
+                .root_repo_common_dir()
+                .map(|p| p.to_string_lossy().into_owned()),
             updated_entries,
             removed_entries,
             scan_id: self.scan_id as u64,
@@ -6071,6 +6109,16 @@ fn parse_gitfile(content: &str) -> anyhow::Result<&Path> {
     Ok(Path::new(path.trim()))
 }
 
+async fn discover_root_repo_common_dir(root_abs_path: &Path, fs: &dyn Fs) -> Option<Arc<Path>> {
+    let root_dot_git = root_abs_path.join(DOT_GIT);
+    if !fs.metadata(&root_dot_git).await.is_ok_and(|m| m.is_some()) {
+        return None;
+    }
+    let dot_git_path: Arc<Path> = root_dot_git.into();
+    let (_, common_dir) = discover_git_paths(&dot_git_path, fs).await;
+    Some(common_dir)
+}
+
 async fn discover_git_paths(dot_git_abs_path: &Arc<Path>, fs: &dyn Fs) -> (Arc<Path>, Arc<Path>) {
     let mut repository_dir_abs_path = dot_git_abs_path.clone();
     let mut common_dir_abs_path = dot_git_abs_path.clone();

crates/worktree/tests/integration/main.rs 🔗

@@ -2736,6 +2736,97 @@ fn check_worktree_entries(
     }
 }
 
+#[gpui::test]
+async fn test_root_repo_common_dir(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    init_test(cx);
+
+    use git::repository::Worktree as GitWorktree;
+
+    let fs = FakeFs::new(executor);
+
+    // Set up a main repo and a linked worktree pointing back to it.
+    fs.insert_tree(
+        path!("/main_repo"),
+        json!({
+            ".git": {},
+            "file.txt": "content",
+        }),
+    )
+    .await;
+    fs.add_linked_worktree_for_repo(
+        Path::new(path!("/main_repo/.git")),
+        false,
+        GitWorktree {
+            path: PathBuf::from(path!("/linked_worktree")),
+            ref_name: Some("refs/heads/feature".into()),
+            sha: "abc123".into(),
+            is_main: false,
+        },
+    )
+    .await;
+    fs.write(
+        path!("/linked_worktree/file.txt").as_ref(),
+        "content".as_bytes(),
+    )
+    .await
+    .unwrap();
+
+    let tree = Worktree::local(
+        path!("/linked_worktree").as_ref(),
+        true,
+        fs.clone(),
+        Arc::default(),
+        true,
+        WorktreeId::from_proto(0),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+    tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
+        .await;
+    cx.run_until_parked();
+
+    // For a linked worktree, root_repo_common_dir should point to the
+    // main repo's .git, not the worktree-specific git directory.
+    tree.read_with(cx, |tree, _| {
+        assert_eq!(
+            tree.snapshot().root_repo_common_dir().map(|p| p.as_ref()),
+            Some(Path::new(path!("/main_repo/.git"))),
+        );
+    });
+
+    let event_count: Rc<Cell<usize>> = Rc::new(Cell::new(0));
+    tree.update(cx, {
+        let event_count = event_count.clone();
+        |_, cx| {
+            cx.subscribe(&cx.entity(), move |_, _, event, _| {
+                if matches!(event, Event::UpdatedRootRepoCommonDir) {
+                    event_count.set(event_count.get() + 1);
+                }
+            })
+            .detach();
+        }
+    });
+
+    // Remove .git — root_repo_common_dir should become None.
+    fs.remove_file(
+        &PathBuf::from(path!("/linked_worktree/.git")),
+        Default::default(),
+    )
+    .await
+    .unwrap();
+    tree.flush_fs_events(cx).await;
+
+    tree.read_with(cx, |tree, _| {
+        assert_eq!(tree.snapshot().root_repo_common_dir(), None);
+    });
+    assert_eq!(
+        event_count.get(),
+        1,
+        "should have emitted UpdatedRootRepoCommonDir on removal"
+    );
+}
+
 fn init_test(cx: &mut gpui::TestAppContext) {
     zlog::init_test();