Detailed changes
@@ -65,6 +65,7 @@ CREATE TABLE "worktrees" (
"scan_id" INTEGER NOT NULL,
"is_complete" BOOL NOT NULL DEFAULT FALSE,
"completed_scan_id" INTEGER NOT NULL,
+ "root_repo_common_dir" VARCHAR,
PRIMARY KEY (project_id, id)
);
@@ -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);
@@ -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)]
@@ -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(),
},
)
@@ -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
@@ -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)]
@@ -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,
@@ -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,
@@ -319,6 +319,7 @@ impl LicenseDetectionWatcher {
}
worktree::Event::DeletedEntry(_)
| worktree::Event::UpdatedGitRepositories(_)
+ | worktree::Event::UpdatedRootRepoCommonDir
| worktree::Event::Deleted => {}
});
@@ -4414,7 +4414,8 @@ impl LspStore {
}
worktree::Event::UpdatedGitRepositories(_)
| worktree::Event::DeletedEntry(_)
- | worktree::Event::Deleted => {}
+ | worktree::Event::Deleted
+ | worktree::Event::UpdatedRootRepoCommonDir => {}
})
.detach()
}
@@ -59,7 +59,7 @@ impl WorktreeRoots {
let path = TriePath::from(entry.path.as_ref());
this.roots.remove(&path);
}
- WorktreeEvent::Deleted => {}
+ WorktreeEvent::Deleted | WorktreeEvent::UpdatedRootRepoCommonDir => {}
}
}),
})
@@ -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();
@@ -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
@@ -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,
@@ -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 = "
@@ -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();
@@ -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();