Implement sidebar rendering of the configured worktrees (#51342)

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
This commit is contained in:
Mikayla Maki 2026-03-12 10:53:38 -07:00 committed by GitHub
parent dcab464608
commit 17adc40d61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 777 additions and 8 deletions

View file

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

View file

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

View file

@ -109,6 +109,7 @@ CREATE TABLE "project_repositories" (
"head_commit_details" VARCHAR,
"remote_upstream_url" VARCHAR,
"remote_origin_url" VARCHAR,
"linked_worktrees" VARCHAR,
PRIMARY KEY (project_id, id)
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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