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

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>
This commit is contained in:
Max Brunsfeld 2026-04-02 21:16:35 -07:00 committed by GitHub
parent 134dec8f95
commit 20f7308677
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 297 additions and 4 deletions

View file

@ -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)
);

View file

@ -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);

View file

@ -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)]

View file

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

View file

@ -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

View file

@ -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)]

View file

@ -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,

View file

@ -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,

View file

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

View file

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

View file

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

View file

@ -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();

View file

@ -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

View file

@ -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,

View file

@ -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 = "

View file

@ -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();

View file

@ -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();