Add support for git branches on remote projects (#19755)

Mikayla Maki created

Release Notes:

- Fixed a bug where the branch switcher could not be used remotely.

Change summary

Cargo.lock                                                    |   1 
crates/collab/src/rpc.rs                                      |   2 
crates/collab/src/tests/integration_tests.rs                  |  92 +
crates/collab/src/tests/remote_editing_collaboration_tests.rs | 132 ++
crates/fs/src/fs.rs                                           |  56 
crates/git/src/repository.rs                                  |  78 +
crates/gpui/src/app.rs                                        |  13 
crates/gpui/src/app/entity_map.rs                             |   9 
crates/gpui/src/app/test_context.rs                           |   6 
crates/gpui/src/global.rs                                     |   1 
crates/gpui/src/gpui.rs                                       |   1 
crates/project/src/project.rs                                 |  28 
crates/project/src/worktree_store.rs                          | 189 +++
crates/proto/proto/zed.proto                                  |  28 
crates/proto/src/proto.rs                                     |   9 
crates/recent_projects/src/ssh_connections.rs                 |   2 
crates/remote_server/src/headless_project.rs                  |   2 
crates/remote_server/src/remote_editing_tests.rs              | 312 ++++
crates/rpc/src/proto_client.rs                                |  21 
crates/settings/src/settings_store.rs                         |   2 
crates/title_bar/src/title_bar.rs                             |   2 
crates/util/src/arc_cow.rs                                    |   6 
crates/vcs_menu/Cargo.toml                                    |   1 
crates/vcs_menu/src/lib.rs                                    | 127 -
crates/worktree/src/worktree.rs                               |   6 
25 files changed, 996 insertions(+), 130 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -12843,6 +12843,7 @@ dependencies = [
  "git",
  "gpui",
  "picker",
+ "project",
  "ui",
  "util",
  "workspace",

crates/collab/src/rpc.rs 🔗

@@ -308,6 +308,8 @@ impl Server {
             .add_request_handler(forward_read_only_project_request::<proto::InlayHints>)
             .add_request_handler(forward_read_only_project_request::<proto::ResolveInlayHint>)
             .add_request_handler(forward_read_only_project_request::<proto::OpenBufferByPath>)
+            .add_request_handler(forward_read_only_project_request::<proto::GitBranches>)
+            .add_request_handler(forward_mutating_project_request::<proto::UpdateGitBranch>)
             .add_request_handler(forward_mutating_project_request::<proto::GetCompletions>)
             .add_request_handler(
                 forward_mutating_project_request::<proto::ApplyCompletionAdditionalEdits>,

crates/collab/src/tests/integration_tests.rs 🔗

@@ -6575,3 +6575,95 @@ async fn test_context_collaboration_with_reconnect(
         assert!(context.buffer().read(cx).read_only());
     });
 }
+
+#[gpui::test]
+async fn test_remote_git_branches(
+    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);
+
+    client_a
+        .fs()
+        .insert_tree("/project", serde_json::json!({ ".git":{} }))
+        .await;
+    let branches = ["main", "dev", "feature-1"];
+    client_a
+        .fs()
+        .insert_branches(Path::new("/project/.git"), &branches);
+
+    let (project_a, worktree_id) = client_a.build_local_project("/project", cx_a).await;
+    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;
+
+    let root_path = ProjectPath::root_path(worktree_id);
+    // Client A sees that a guest has joined.
+    executor.run_until_parked();
+
+    let branches_b = cx_b
+        .update(|cx| project_b.update(cx, |project, cx| project.branches(root_path.clone(), cx)))
+        .await
+        .unwrap();
+
+    let new_branch = branches[2];
+
+    let branches_b = branches_b
+        .into_iter()
+        .map(|branch| branch.name)
+        .collect::<Vec<_>>();
+
+    assert_eq!(&branches_b, &branches);
+
+    cx_b.update(|cx| {
+        project_b.update(cx, |project, cx| {
+            project.update_or_create_branch(root_path.clone(), new_branch.to_string(), cx)
+        })
+    })
+    .await
+    .unwrap();
+
+    executor.run_until_parked();
+
+    let host_branch = cx_a.update(|cx| {
+        project_a.update(cx, |project, cx| {
+            project.worktree_store().update(cx, |worktree_store, cx| {
+                worktree_store
+                    .current_branch(root_path.clone(), cx)
+                    .unwrap()
+            })
+        })
+    });
+
+    assert_eq!(host_branch.as_ref(), branches[2]);
+
+    // Also try creating a new branch
+    cx_b.update(|cx| {
+        project_b.update(cx, |project, cx| {
+            project.update_or_create_branch(root_path.clone(), "totally-new-branch".to_string(), cx)
+        })
+    })
+    .await
+    .unwrap();
+
+    executor.run_until_parked();
+
+    let host_branch = cx_a.update(|cx| {
+        project_a.update(cx, |project, cx| {
+            project.worktree_store().update(cx, |worktree_store, cx| {
+                worktree_store.current_branch(root_path, cx).unwrap()
+            })
+        })
+    });
+
+    assert_eq!(host_branch.as_ref(), "totally-new-branch");
+}

crates/collab/src/tests/remote_editing_collaboration_tests.rs 🔗

@@ -1,7 +1,7 @@
 use crate::tests::TestServer;
 use call::ActiveCall;
 use fs::{FakeFs, Fs as _};
-use gpui::{Context as _, TestAppContext};
+use gpui::{BackgroundExecutor, Context as _, TestAppContext};
 use http_client::BlockedHttpClient;
 use language::{language_settings::language_settings, LanguageRegistry};
 use node_runtime::NodeRuntime;
@@ -174,3 +174,133 @@ async fn test_sharing_an_ssh_remote_project(
         );
     });
 }
+
+#[gpui::test]
+async fn test_ssh_collaboration_git_branches(
+    executor: BackgroundExecutor,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+    server_cx: &mut TestAppContext,
+) {
+    cx_a.set_name("a");
+    cx_b.set_name("b");
+    server_cx.set_name("server");
+
+    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;
+
+    // Set up project on remote FS
+    let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
+    let remote_fs = FakeFs::new(server_cx.executor());
+    remote_fs
+        .insert_tree("/project", serde_json::json!({ ".git":{} }))
+        .await;
+
+    let branches = ["main", "dev", "feature-1"];
+    remote_fs.insert_branches(Path::new("/project/.git"), &branches);
+
+    // User A connects to the remote project via SSH.
+    server_cx.update(HeadlessProject::init);
+    let remote_http_client = Arc::new(BlockedHttpClient);
+    let node = NodeRuntime::unavailable();
+    let languages = Arc::new(LanguageRegistry::new(server_cx.executor()));
+    let headless_project = server_cx.new_model(|cx| {
+        client::init_settings(cx);
+        HeadlessProject::new(
+            HeadlessAppState {
+                session: server_ssh,
+                fs: remote_fs.clone(),
+                http_client: remote_http_client,
+                node_runtime: node,
+                languages,
+            },
+            cx,
+        )
+    });
+
+    let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
+    let (project_a, worktree_id) = client_a
+        .build_ssh_project("/project", client_ssh, cx_a)
+        .await;
+
+    // While the SSH worktree is being scanned, user A shares the remote project.
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+
+    // User B joins the project.
+    let project_b = client_b.join_remote_project(project_id, cx_b).await;
+
+    // Give client A sometime to see that B has joined, and that the headless server
+    // has some git repositories
+    executor.run_until_parked();
+
+    let root_path = ProjectPath::root_path(worktree_id);
+
+    let branches_b = cx_b
+        .update(|cx| project_b.update(cx, |project, cx| project.branches(root_path.clone(), cx)))
+        .await
+        .unwrap();
+
+    let new_branch = branches[2];
+
+    let branches_b = branches_b
+        .into_iter()
+        .map(|branch| branch.name)
+        .collect::<Vec<_>>();
+
+    assert_eq!(&branches_b, &branches);
+
+    cx_b.update(|cx| {
+        project_b.update(cx, |project, cx| {
+            project.update_or_create_branch(root_path.clone(), new_branch.to_string(), cx)
+        })
+    })
+    .await
+    .unwrap();
+
+    executor.run_until_parked();
+
+    let server_branch = server_cx.update(|cx| {
+        headless_project.update(cx, |headless_project, cx| {
+            headless_project
+                .worktree_store
+                .update(cx, |worktree_store, cx| {
+                    worktree_store
+                        .current_branch(root_path.clone(), cx)
+                        .unwrap()
+                })
+        })
+    });
+
+    assert_eq!(server_branch.as_ref(), branches[2]);
+
+    // Also try creating a new branch
+    cx_b.update(|cx| {
+        project_b.update(cx, |project, cx| {
+            project.update_or_create_branch(root_path.clone(), "totally-new-branch".to_string(), cx)
+        })
+    })
+    .await
+    .unwrap();
+
+    executor.run_until_parked();
+
+    let server_branch = server_cx.update(|cx| {
+        headless_project.update(cx, |headless_project, cx| {
+            headless_project
+                .worktree_store
+                .update(cx, |worktree_store, cx| {
+                    worktree_store.current_branch(root_path, cx).unwrap()
+                })
+        })
+    });
+
+    assert_eq!(server_branch.as_ref(), "totally-new-branch");
+}

crates/fs/src/fs.rs 🔗

@@ -813,6 +813,7 @@ struct FakeFsState {
     root: Arc<Mutex<FakeFsEntry>>,
     next_inode: u64,
     next_mtime: SystemTime,
+    git_event_tx: smol::channel::Sender<PathBuf>,
     event_txs: Vec<smol::channel::Sender<Vec<PathEvent>>>,
     events_paused: bool,
     buffered_events: Vec<PathEvent>,
@@ -969,8 +970,10 @@ impl FakeFs {
     const SYSTEMTIME_INTERVAL: u64 = 100;
 
     pub fn new(executor: gpui::BackgroundExecutor) -> Arc<Self> {
-        Arc::new(Self {
-            executor,
+        let (tx, mut rx) = smol::channel::bounded::<PathBuf>(10);
+
+        let this = Arc::new(Self {
+            executor: executor.clone(),
             state: Mutex::new(FakeFsState {
                 root: Arc::new(Mutex::new(FakeFsEntry::Dir {
                     inode: 0,
@@ -979,6 +982,7 @@ impl FakeFs {
                     entries: Default::default(),
                     git_repo_state: None,
                 })),
+                git_event_tx: tx,
                 next_mtime: SystemTime::UNIX_EPOCH,
                 next_inode: 1,
                 event_txs: Default::default(),
@@ -987,7 +991,22 @@ impl FakeFs {
                 read_dir_call_count: 0,
                 metadata_call_count: 0,
             }),
-        })
+        });
+
+        executor.spawn({
+            let this = this.clone();
+            async move {
+                while let Some(git_event) = rx.next().await {
+                    if let Some(mut state) = this.state.try_lock() {
+                        state.emit_event([(git_event, None)]);
+                    } else {
+                        panic!("Failed to lock file system state, this execution would have caused a test hang");
+                    }
+                }
+            }
+        }).detach();
+
+        this
     }
 
     pub fn set_next_mtime(&self, next_mtime: SystemTime) {
@@ -1181,7 +1200,12 @@ impl FakeFs {
         let mut entry = entry.lock();
 
         if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry {
-            let repo_state = git_repo_state.get_or_insert_with(Default::default);
+            let repo_state = git_repo_state.get_or_insert_with(|| {
+                Arc::new(Mutex::new(FakeGitRepositoryState::new(
+                    dot_git.to_path_buf(),
+                    state.git_event_tx.clone(),
+                )))
+            });
             let mut repo_state = repo_state.lock();
 
             f(&mut repo_state);
@@ -1196,7 +1220,22 @@ impl FakeFs {
 
     pub fn set_branch_name(&self, dot_git: &Path, branch: Option<impl Into<String>>) {
         self.with_git_state(dot_git, true, |state| {
-            state.branch_name = branch.map(Into::into)
+            let branch = branch.map(Into::into);
+            state.branches.extend(branch.clone());
+            state.current_branch_name = branch.map(Into::into)
+        })
+    }
+
+    pub fn insert_branches(&self, dot_git: &Path, branches: &[&str]) {
+        self.with_git_state(dot_git, true, |state| {
+            if let Some(first) = branches.first() {
+                if state.current_branch_name.is_none() {
+                    state.current_branch_name = Some(first.to_string())
+                }
+            }
+            state
+                .branches
+                .extend(branches.iter().map(ToString::to_string));
         })
     }
 
@@ -1836,7 +1875,12 @@ impl Fs for FakeFs {
         let mut entry = entry.lock();
         if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry {
             let state = git_repo_state
-                .get_or_insert_with(|| Arc::new(Mutex::new(FakeGitRepositoryState::default())))
+                .get_or_insert_with(|| {
+                    Arc::new(Mutex::new(FakeGitRepositoryState::new(
+                        abs_dot_git.to_path_buf(),
+                        state.git_event_tx.clone(),
+                    )))
+                })
                 .clone();
             Some(git::repository::FakeGitRepository::open(state))
         } else {

crates/git/src/repository.rs 🔗

@@ -1,8 +1,9 @@
 use crate::GitHostingProviderRegistry;
 use crate::{blame::Blame, status::GitStatus};
 use anyhow::{Context, Result};
-use collections::HashMap;
+use collections::{HashMap, HashSet};
 use git2::BranchType;
+use gpui::SharedString;
 use parking_lot::Mutex;
 use rope::Rope;
 use serde::{Deserialize, Serialize};
@@ -17,7 +18,7 @@ use util::ResultExt;
 #[derive(Clone, Debug, Hash, PartialEq)]
 pub struct Branch {
     pub is_head: bool,
-    pub name: Box<str>,
+    pub name: SharedString,
     /// Timestamp of most recent commit, normalized to Unix Epoch format.
     pub unix_timestamp: Option<i64>,
 }
@@ -41,6 +42,7 @@ pub trait GitRepository: Send + Sync {
     fn branches(&self) -> Result<Vec<Branch>>;
     fn change_branch(&self, _: &str) -> Result<()>;
     fn create_branch(&self, _: &str) -> Result<()>;
+    fn branch_exits(&self, _: &str) -> Result<bool>;
 
     fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame>;
 }
@@ -132,6 +134,18 @@ impl GitRepository for RealGitRepository {
         GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
     }
 
+    fn branch_exits(&self, name: &str) -> Result<bool> {
+        let repo = self.repository.lock();
+        let branch = repo.find_branch(name, BranchType::Local);
+        match branch {
+            Ok(_) => Ok(true),
+            Err(e) => match e.code() {
+                git2::ErrorCode::NotFound => Ok(false),
+                _ => Err(anyhow::anyhow!(e)),
+            },
+        }
+    }
+
     fn branches(&self) -> Result<Vec<Branch>> {
         let repo = self.repository.lock();
         let local_branches = repo.branches(Some(BranchType::Local))?;
@@ -139,7 +153,11 @@ impl GitRepository for RealGitRepository {
             .filter_map(|branch| {
                 branch.ok().and_then(|(branch, _)| {
                     let is_head = branch.is_head();
-                    let name = branch.name().ok().flatten().map(Box::from)?;
+                    let name = branch
+                        .name()
+                        .ok()
+                        .flatten()
+                        .map(|name| name.to_string().into())?;
                     let timestamp = branch.get().peel_to_commit().ok()?.time();
                     let unix_timestamp = timestamp.seconds();
                     let timezone_offset = timestamp.offset_minutes();
@@ -201,17 +219,20 @@ impl GitRepository for RealGitRepository {
     }
 }
 
-#[derive(Debug, Clone, Default)]
+#[derive(Debug, Clone)]
 pub struct FakeGitRepository {
     state: Arc<Mutex<FakeGitRepositoryState>>,
 }
 
-#[derive(Debug, Clone, Default)]
+#[derive(Debug, Clone)]
 pub struct FakeGitRepositoryState {
+    pub path: PathBuf,
+    pub event_emitter: smol::channel::Sender<PathBuf>,
     pub index_contents: HashMap<PathBuf, String>,
     pub blames: HashMap<PathBuf, Blame>,
     pub worktree_statuses: HashMap<RepoPath, GitFileStatus>,
-    pub branch_name: Option<String>,
+    pub current_branch_name: Option<String>,
+    pub branches: HashSet<String>,
 }
 
 impl FakeGitRepository {
@@ -220,6 +241,20 @@ impl FakeGitRepository {
     }
 }
 
+impl FakeGitRepositoryState {
+    pub fn new(path: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
+        FakeGitRepositoryState {
+            path,
+            event_emitter,
+            index_contents: Default::default(),
+            blames: Default::default(),
+            worktree_statuses: Default::default(),
+            current_branch_name: Default::default(),
+            branches: Default::default(),
+        }
+    }
+}
+
 impl GitRepository for FakeGitRepository {
     fn reload_index(&self) {}
 
@@ -234,7 +269,7 @@ impl GitRepository for FakeGitRepository {
 
     fn branch_name(&self) -> Option<String> {
         let state = self.state.lock();
-        state.branch_name.clone()
+        state.current_branch_name.clone()
     }
 
     fn head_sha(&self) -> Option<String> {
@@ -264,18 +299,41 @@ impl GitRepository for FakeGitRepository {
     }
 
     fn branches(&self) -> Result<Vec<Branch>> {
-        Ok(vec![])
+        let state = self.state.lock();
+        let current_branch = &state.current_branch_name;
+        Ok(state
+            .branches
+            .iter()
+            .map(|branch_name| Branch {
+                is_head: Some(branch_name) == current_branch.as_ref(),
+                name: branch_name.into(),
+                unix_timestamp: None,
+            })
+            .collect())
+    }
+
+    fn branch_exits(&self, name: &str) -> Result<bool> {
+        let state = self.state.lock();
+        Ok(state.branches.contains(name))
     }
 
     fn change_branch(&self, name: &str) -> Result<()> {
         let mut state = self.state.lock();
-        state.branch_name = Some(name.to_owned());
+        state.current_branch_name = Some(name.to_owned());
+        state
+            .event_emitter
+            .try_send(state.path.clone())
+            .expect("Dropped repo change event");
         Ok(())
     }
 
     fn create_branch(&self, name: &str) -> Result<()> {
         let mut state = self.state.lock();
-        state.branch_name = Some(name.to_owned());
+        state.branches.insert(name.to_owned());
+        state
+            .event_emitter
+            .try_send(state.path.clone())
+            .expect("Dropped repo change event");
         Ok(())
     }
 

crates/gpui/src/app.rs 🔗

@@ -256,6 +256,9 @@ pub struct AppContext {
     pub(crate) layout_id_buffer: Vec<LayoutId>, // We recycle this memory across layout requests.
     pub(crate) propagate_event: bool,
     pub(crate) prompt_builder: Option<PromptBuilder>,
+
+    #[cfg(any(test, feature = "test-support", debug_assertions))]
+    pub(crate) name: Option<&'static str>,
 }
 
 impl AppContext {
@@ -309,6 +312,9 @@ impl AppContext {
                 layout_id_buffer: Default::default(),
                 propagate_event: true,
                 prompt_builder: Some(PromptBuilder::Default),
+
+                #[cfg(any(test, feature = "test-support", debug_assertions))]
+                name: None,
             }),
         });
 
@@ -988,6 +994,7 @@ impl AppContext {
     }
 
     /// Move the global of the given type to the stack.
+    #[track_caller]
     pub(crate) fn lease_global<G: Global>(&mut self) -> GlobalLease<G> {
         GlobalLease::new(
             self.globals_by_type
@@ -1319,6 +1326,12 @@ impl AppContext {
 
         (task, is_first)
     }
+
+    /// Get the name for this App.
+    #[cfg(any(test, feature = "test-support", debug_assertions))]
+    pub fn get_name(&self) -> &'static str {
+        self.name.as_ref().unwrap()
+    }
 }
 
 impl Context for AppContext {

crates/gpui/src/app/entity_map.rs 🔗

@@ -536,6 +536,15 @@ impl AnyWeakModel {
     }
 }
 
+impl std::fmt::Debug for AnyWeakModel {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct(type_name::<Self>())
+            .field("entity_id", &self.entity_id)
+            .field("entity_type", &self.entity_type)
+            .finish()
+    }
+}
+
 impl<T> From<WeakModel<T>> for AnyWeakModel {
     fn from(model: WeakModel<T>) -> Self {
         model.any_model

crates/gpui/src/app/test_context.rs 🔗

@@ -478,6 +478,12 @@ impl TestAppContext {
         .await
         .unwrap();
     }
+
+    /// Set a name for this App.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn set_name(&mut self, name: &'static str) {
+        self.update(|cx| cx.name = Some(name))
+    }
 }
 
 impl<T: 'static> Model<T> {

crates/gpui/src/global.rs 🔗

@@ -57,6 +57,7 @@ pub trait UpdateGlobal {
 }
 
 impl<T: Global> UpdateGlobal for T {
+    #[track_caller]
     fn update_global<C, F, R>(cx: &mut C, update: F) -> R
     where
         C: BorrowAppContext,

crates/gpui/src/gpui.rs 🔗

@@ -306,6 +306,7 @@ where
         self.borrow_mut().set_global(global)
     }
 
+    #[track_caller]
     fn update_global<G, R>(&mut self, f: impl FnOnce(&mut G, &mut Self) -> R) -> R
     where
         G: Global,

crates/project/src/project.rs 🔗

@@ -288,6 +288,13 @@ impl ProjectPath {
             path: self.path.to_string_lossy().to_string(),
         }
     }
+
+    pub fn root_path(worktree_id: WorktreeId) -> Self {
+        Self {
+            worktree_id,
+            path: Path::new("").into(),
+        }
+    }
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -701,7 +708,7 @@ impl Project {
 
             let ssh_proto = ssh.read(cx).proto_client();
             let worktree_store =
-                cx.new_model(|_| WorktreeStore::remote(false, ssh_proto.clone(), 0));
+                cx.new_model(|_| WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID));
             cx.subscribe(&worktree_store, Self::on_worktree_store_event)
                 .detach();
 
@@ -3370,6 +3377,25 @@ impl Project {
         worktree.get_local_repo(&root_entry)?.repo().clone().into()
     }
 
+    pub fn branches(
+        &self,
+        project_path: ProjectPath,
+        cx: &AppContext,
+    ) -> Task<Result<Vec<git::repository::Branch>>> {
+        self.worktree_store().read(cx).branches(project_path, cx)
+    }
+
+    pub fn update_or_create_branch(
+        &self,
+        repository: ProjectPath,
+        new_branch: String,
+        cx: &AppContext,
+    ) -> Task<Result<()>> {
+        self.worktree_store()
+            .read(cx)
+            .update_or_create_branch(repository, new_branch, cx)
+    }
+
     pub fn blame_buffer(
         &self,
         buffer: &Model<Buffer>,

crates/project/src/worktree_store.rs 🔗

@@ -73,6 +73,8 @@ impl WorktreeStore {
         client.add_model_request_handler(Self::handle_copy_project_entry);
         client.add_model_request_handler(Self::handle_delete_project_entry);
         client.add_model_request_handler(Self::handle_expand_project_entry);
+        client.add_model_request_handler(Self::handle_git_branches);
+        client.add_model_request_handler(Self::handle_update_branch);
     }
 
     pub fn local(retain_worktrees: bool, fs: Arc<dyn Fs>) -> Self {
@@ -127,6 +129,13 @@ impl WorktreeStore {
             .find(|worktree| worktree.read(cx).id() == id)
     }
 
+    pub fn current_branch(&self, repository: ProjectPath, cx: &AppContext) -> Option<Arc<str>> {
+        self.worktree_for_id(repository.worktree_id, cx)?
+            .read(cx)
+            .git_entry(repository.path)?
+            .branch()
+    }
+
     pub fn worktree_for_entry(
         &self,
         entry_id: ProjectEntryId,
@@ -836,6 +845,131 @@ impl WorktreeStore {
         Ok(())
     }
 
+    pub fn branches(
+        &self,
+        project_path: ProjectPath,
+        cx: &AppContext,
+    ) -> Task<Result<Vec<git::repository::Branch>>> {
+        let Some(worktree) = self.worktree_for_id(project_path.worktree_id, cx) else {
+            return Task::ready(Err(anyhow!("No worktree found for ProjectPath")));
+        };
+
+        match worktree.read(cx) {
+            Worktree::Local(local_worktree) => {
+                let branches = util::maybe!({
+                    let worktree_error = |error| {
+                        format!(
+                            "{} for worktree {}",
+                            error,
+                            local_worktree.abs_path().to_string_lossy()
+                        )
+                    };
+
+                    let entry = local_worktree
+                        .git_entry(project_path.path)
+                        .with_context(|| worktree_error("No git entry found"))?;
+
+                    let repo = local_worktree
+                        .get_local_repo(&entry)
+                        .with_context(|| worktree_error("No repository found"))?
+                        .repo()
+                        .clone();
+
+                    repo.branches()
+                });
+
+                Task::ready(branches)
+            }
+            Worktree::Remote(remote_worktree) => {
+                let request = remote_worktree.client().request(proto::GitBranches {
+                    project_id: remote_worktree.project_id(),
+                    repository: Some(proto::ProjectPath {
+                        worktree_id: project_path.worktree_id.to_proto(),
+                        path: project_path.path.to_string_lossy().to_string(), // Root path
+                    }),
+                });
+
+                cx.background_executor().spawn(async move {
+                    let response = request.await?;
+
+                    let branches = response
+                        .branches
+                        .into_iter()
+                        .map(|proto_branch| git::repository::Branch {
+                            is_head: proto_branch.is_head,
+                            name: proto_branch.name.into(),
+                            unix_timestamp: proto_branch
+                                .unix_timestamp
+                                .map(|timestamp| timestamp as i64),
+                        })
+                        .collect();
+
+                    Ok(branches)
+                })
+            }
+        }
+    }
+
+    pub fn update_or_create_branch(
+        &self,
+        repository: ProjectPath,
+        new_branch: String,
+        cx: &AppContext,
+    ) -> Task<Result<()>> {
+        let Some(worktree) = self.worktree_for_id(repository.worktree_id, cx) else {
+            return Task::ready(Err(anyhow!("No worktree found for ProjectPath")));
+        };
+
+        match worktree.read(cx) {
+            Worktree::Local(local_worktree) => {
+                let result = util::maybe!({
+                    let worktree_error = |error| {
+                        format!(
+                            "{} for worktree {}",
+                            error,
+                            local_worktree.abs_path().to_string_lossy()
+                        )
+                    };
+
+                    let entry = local_worktree
+                        .git_entry(repository.path)
+                        .with_context(|| worktree_error("No git entry found"))?;
+
+                    let repo = local_worktree
+                        .get_local_repo(&entry)
+                        .with_context(|| worktree_error("No repository found"))?
+                        .repo()
+                        .clone();
+
+                    if !repo.branch_exits(&new_branch)? {
+                        repo.create_branch(&new_branch)?;
+                    }
+
+                    repo.change_branch(&new_branch)?;
+
+                    Ok(())
+                });
+
+                Task::ready(result)
+            }
+            Worktree::Remote(remote_worktree) => {
+                let request = remote_worktree.client().request(proto::UpdateGitBranch {
+                    project_id: remote_worktree.project_id(),
+                    repository: Some(proto::ProjectPath {
+                        worktree_id: repository.worktree_id.to_proto(),
+                        path: repository.path.to_string_lossy().to_string(), // Root path
+                    }),
+                    branch_name: new_branch,
+                });
+
+                cx.background_executor().spawn(async move {
+                    request.await?;
+                    Ok(())
+                })
+            }
+        }
+    }
+
     async fn filter_paths(
         fs: &Arc<dyn Fs>,
         mut input: Receiver<MatchingEntry>,
@@ -917,6 +1051,61 @@ impl WorktreeStore {
             .ok_or_else(|| anyhow!("invalid request"))?;
         Worktree::handle_expand_entry(worktree, envelope.payload, cx).await
     }
+
+    pub async fn handle_git_branches(
+        this: Model<Self>,
+        branches: TypedEnvelope<proto::GitBranches>,
+        cx: AsyncAppContext,
+    ) -> Result<proto::GitBranchesResponse> {
+        let project_path = branches
+            .payload
+            .repository
+            .clone()
+            .context("Invalid GitBranches call")?;
+        let project_path = ProjectPath {
+            worktree_id: WorktreeId::from_proto(project_path.worktree_id),
+            path: Path::new(&project_path.path).into(),
+        };
+
+        let branches = this
+            .read_with(&cx, |this, cx| this.branches(project_path, cx))?
+            .await?;
+
+        Ok(proto::GitBranchesResponse {
+            branches: branches
+                .into_iter()
+                .map(|branch| proto::Branch {
+                    is_head: branch.is_head,
+                    name: branch.name.to_string(),
+                    unix_timestamp: branch.unix_timestamp.map(|timestamp| timestamp as u64),
+                })
+                .collect(),
+        })
+    }
+
+    pub async fn handle_update_branch(
+        this: Model<Self>,
+        update_branch: TypedEnvelope<proto::UpdateGitBranch>,
+        cx: AsyncAppContext,
+    ) -> Result<proto::Ack> {
+        let project_path = update_branch
+            .payload
+            .repository
+            .clone()
+            .context("Invalid GitBranches call")?;
+        let project_path = ProjectPath {
+            worktree_id: WorktreeId::from_proto(project_path.worktree_id),
+            path: Path::new(&project_path.path).into(),
+        };
+        let new_branch = update_branch.payload.branch_name;
+
+        this.read_with(&cx, |this, cx| {
+            this.update_or_create_branch(project_path, new_branch, cx)
+        })?
+        .await?;
+
+        Ok(proto::Ack {})
+    }
 }
 
 #[derive(Clone, Debug)]

crates/proto/proto/zed.proto 🔗

@@ -281,7 +281,12 @@ message Envelope {
         FlushBufferedMessages flush_buffered_messages = 267;
 
         LanguageServerPromptRequest language_server_prompt_request = 268;
-        LanguageServerPromptResponse language_server_prompt_response = 269; // current max
+        LanguageServerPromptResponse language_server_prompt_response = 269;
+
+        GitBranches git_branches = 270;
+        GitBranchesResponse git_branches_response = 271;
+
+        UpdateGitBranch update_git_branch = 272; // current max
     }
 
 
@@ -2432,3 +2437,24 @@ message LanguageServerPromptRequest {
 message LanguageServerPromptResponse {
     optional uint64 action_response = 1;
 }
+
+message Branch {
+    bool is_head = 1;
+    string name = 2;
+    optional uint64 unix_timestamp = 3;
+}
+
+message GitBranches {
+    uint64 project_id = 1;
+    ProjectPath repository = 2;
+}
+
+message GitBranchesResponse {
+    repeated Branch branches = 1;
+}
+
+message UpdateGitBranch {
+    uint64 project_id = 1;
+    string branch_name = 2;
+    ProjectPath repository = 3;
+}

crates/proto/src/proto.rs 🔗

@@ -357,6 +357,9 @@ messages!(
     (FlushBufferedMessages, Foreground),
     (LanguageServerPromptRequest, Foreground),
     (LanguageServerPromptResponse, Foreground),
+    (GitBranches, Background),
+    (GitBranchesResponse, Background),
+    (UpdateGitBranch, Background)
 );
 
 request_messages!(
@@ -473,6 +476,8 @@ request_messages!(
     (GetPermalinkToLine, GetPermalinkToLineResponse),
     (FlushBufferedMessages, Ack),
     (LanguageServerPromptRequest, LanguageServerPromptResponse),
+    (GitBranches, GitBranchesResponse),
+    (UpdateGitBranch, Ack)
 );
 
 entity_messages!(
@@ -550,7 +555,9 @@ entity_messages!(
     HideToast,
     OpenServerSettings,
     GetPermalinkToLine,
-    LanguageServerPromptRequest
+    LanguageServerPromptRequest,
+    GitBranches,
+    UpdateGitBranch
 );
 
 entity_messages!(

crates/recent_projects/src/ssh_connections.rs 🔗

@@ -631,7 +631,7 @@ impl SshClientDelegate {
 
             self.update_status(
                 Some(&format!(
-                    "Building remote server binary from source for {}",
+                    "Building remote server binary from source for {} with Docker",
                     &triple
                 )),
                 cx,

crates/remote_server/src/headless_project.rs 🔗

@@ -1,6 +1,6 @@
 use anyhow::{anyhow, Result};
 use fs::Fs;
-use gpui::{AppContext, AsyncAppContext, Context, Model, ModelContext, PromptLevel};
+use gpui::{AppContext, AsyncAppContext, Context as _, Model, ModelContext, PromptLevel};
 use http_client::HttpClient;
 use language::{proto::serialize_operation, Buffer, BufferEvent, LanguageRegistry};
 use node_runtime::NodeRuntime;

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -26,7 +26,29 @@ use std::{
 
 #[gpui::test]
 async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
-    let (project, _headless, fs) = init_test(cx, server_cx).await;
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        "/code",
+        json!({
+            "project1": {
+                ".git": {},
+                "README.md": "# project 1",
+                "src": {
+                    "lib.rs": "fn one() -> usize { 1 }"
+                }
+            },
+            "project2": {
+                "README.md": "# project 2",
+            },
+        }),
+    )
+    .await;
+    fs.set_index_for_repo(
+        Path::new("/code/project1/.git"),
+        &[(Path::new("src/lib.rs"), "fn one() -> usize { 0 }".into())],
+    );
+
+    let (project, _headless) = init_test(&fs, cx, server_cx).await;
     let (worktree, _) = project
         .update(cx, |project, cx| {
             project.find_or_create_worktree("/code/project1", true, cx)
@@ -128,7 +150,22 @@ async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut Test
 
 #[gpui::test]
 async fn test_remote_project_search(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
-    let (project, headless, _) = init_test(cx, server_cx).await;
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        "/code",
+        json!({
+            "project1": {
+                ".git": {},
+                "README.md": "# project 1",
+                "src": {
+                    "lib.rs": "fn one() -> usize { 1 }"
+                }
+            },
+        }),
+    )
+    .await;
+
+    let (project, headless) = init_test(&fs, cx, server_cx).await;
 
     project
         .update(cx, |project, cx| {
@@ -193,7 +230,22 @@ async fn test_remote_project_search(cx: &mut TestAppContext, server_cx: &mut Tes
 
 #[gpui::test]
 async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
-    let (project, headless, fs) = init_test(cx, server_cx).await;
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        "/code",
+        json!({
+            "project1": {
+                ".git": {},
+                "README.md": "# project 1",
+                "src": {
+                    "lib.rs": "fn one() -> usize { 1 }"
+                }
+            },
+        }),
+    )
+    .await;
+
+    let (project, headless) = init_test(&fs, cx, server_cx).await;
 
     cx.update_global(|settings_store: &mut SettingsStore, cx| {
         settings_store.set_user_settings(
@@ -304,7 +356,22 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo
 
 #[gpui::test]
 async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
-    let (project, headless, fs) = init_test(cx, server_cx).await;
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        "/code",
+        json!({
+            "project1": {
+                ".git": {},
+                "README.md": "# project 1",
+                "src": {
+                    "lib.rs": "fn one() -> usize { 1 }"
+                }
+            },
+        }),
+    )
+    .await;
+
+    let (project, headless) = init_test(&fs, cx, server_cx).await;
 
     fs.insert_tree(
         "/code/project1/.zed",
@@ -463,7 +530,22 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
 
 #[gpui::test]
 async fn test_remote_reload(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
-    let (project, _headless, fs) = init_test(cx, server_cx).await;
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        "/code",
+        json!({
+            "project1": {
+                ".git": {},
+                "README.md": "# project 1",
+                "src": {
+                    "lib.rs": "fn one() -> usize { 1 }"
+                }
+            },
+        }),
+    )
+    .await;
+
+    let (project, _headless) = init_test(&fs, cx, server_cx).await;
     let (worktree, _) = project
         .update(cx, |project, cx| {
             project.find_or_create_worktree("/code/project1", true, cx)
@@ -523,7 +605,22 @@ async fn test_remote_reload(cx: &mut TestAppContext, server_cx: &mut TestAppCont
 
 #[gpui::test]
 async fn test_remote_resolve_file_path(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
-    let (project, _headless, _fs) = init_test(cx, server_cx).await;
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        "/code",
+        json!({
+            "project1": {
+                ".git": {},
+                "README.md": "# project 1",
+                "src": {
+                    "lib.rs": "fn one() -> usize { 1 }"
+                }
+            },
+        }),
+    )
+    .await;
+
+    let (project, _headless) = init_test(&fs, cx, server_cx).await;
     let (worktree, _) = project
         .update(cx, |project, cx| {
             project.find_or_create_worktree("/code/project1", true, cx)
@@ -566,7 +663,22 @@ async fn test_remote_resolve_file_path(cx: &mut TestAppContext, server_cx: &mut
 
 #[gpui::test(iterations = 10)]
 async fn test_canceling_buffer_opening(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
-    let (project, _headless, _fs) = init_test(cx, server_cx).await;
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        "/code",
+        json!({
+            "project1": {
+                ".git": {},
+                "README.md": "# project 1",
+                "src": {
+                    "lib.rs": "fn one() -> usize { 1 }"
+                }
+            },
+        }),
+    )
+    .await;
+
+    let (project, _headless) = init_test(&fs, cx, server_cx).await;
     let (worktree, _) = project
         .update(cx, |project, cx| {
             project.find_or_create_worktree("/code/project1", true, cx)
@@ -597,7 +709,25 @@ async fn test_adding_then_removing_then_adding_worktrees(
     cx: &mut TestAppContext,
     server_cx: &mut TestAppContext,
 ) {
-    let (project, _headless, _fs) = init_test(cx, server_cx).await;
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        "/code",
+        json!({
+            "project1": {
+                ".git": {},
+                "README.md": "# project 1",
+                "src": {
+                    "lib.rs": "fn one() -> usize { 1 }"
+                }
+            },
+            "project2": {
+                "README.md": "# project 2",
+            },
+        }),
+    )
+    .await;
+
+    let (project, _headless) = init_test(&fs, cx, server_cx).await;
     let (_worktree, _) = project
         .update(cx, |project, cx| {
             project.find_or_create_worktree("/code/project1", true, cx)
@@ -636,9 +766,25 @@ async fn test_adding_then_removing_then_adding_worktrees(
 
 #[gpui::test]
 async fn test_open_server_settings(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
-    let (project, _headless, _fs) = init_test(cx, server_cx).await;
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        "/code",
+        json!({
+            "project1": {
+                ".git": {},
+                "README.md": "# project 1",
+                "src": {
+                    "lib.rs": "fn one() -> usize { 1 }"
+                }
+            },
+        }),
+    )
+    .await;
+
+    let (project, _headless) = init_test(&fs, cx, server_cx).await;
     let buffer = project.update(cx, |project, cx| project.open_server_settings(cx));
     cx.executor().run_until_parked();
+
     let buffer = buffer.await.unwrap();
 
     cx.update(|cx| {
@@ -651,7 +797,22 @@ async fn test_open_server_settings(cx: &mut TestAppContext, server_cx: &mut Test
 
 #[gpui::test(iterations = 20)]
 async fn test_reconnect(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
-    let (project, _headless, fs) = init_test(cx, server_cx).await;
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        "/code",
+        json!({
+            "project1": {
+                ".git": {},
+                "README.md": "# project 1",
+                "src": {
+                    "lib.rs": "fn one() -> usize { 1 }"
+                }
+            },
+        }),
+    )
+    .await;
+
+    let (project, _headless) = init_test(&fs, cx, server_cx).await;
 
     let (worktree, _) = project
         .update(cx, |project, cx| {
@@ -690,19 +851,8 @@ async fn test_reconnect(cx: &mut TestAppContext, server_cx: &mut TestAppContext)
     );
 }
 
-fn init_logger() {
-    if std::env::var("RUST_LOG").is_ok() {
-        env_logger::try_init().ok();
-    }
-}
-
-async fn init_test(
-    cx: &mut TestAppContext,
-    server_cx: &mut TestAppContext,
-) -> (Model<Project>, Model<HeadlessProject>, Arc<FakeFs>) {
-    init_logger();
-
-    let (opts, ssh_server_client) = SshRemoteClient::fake_server(cx, server_cx);
+#[gpui::test]
+async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
     let fs = FakeFs::new(server_cx.executor());
     fs.insert_tree(
         "/code",
@@ -710,32 +860,109 @@ async fn init_test(
             "project1": {
                 ".git": {},
                 "README.md": "# project 1",
-                "src": {
-                    "lib.rs": "fn one() -> usize { 1 }"
-                }
-            },
-            "project2": {
-                "README.md": "# project 2",
             },
         }),
     )
     .await;
-    fs.set_index_for_repo(
-        Path::new("/code/project1/.git"),
-        &[(Path::new("src/lib.rs"), "fn one() -> usize { 0 }".into())],
-    );
 
-    server_cx.update(HeadlessProject::init);
+    let (project, headless_project) = init_test(&fs, cx, server_cx).await;
+    let branches = ["main", "dev", "feature-1"];
+    fs.insert_branches(Path::new("/code/project1/.git"), &branches);
+
+    let (worktree, _) = project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree("/code/project1", true, cx)
+        })
+        .await
+        .unwrap();
+
+    let worktree_id = cx.update(|cx| worktree.read(cx).id());
+    let root_path = ProjectPath::root_path(worktree_id);
+    // Give the worktree a bit of time to index the file system
+    cx.run_until_parked();
+
+    let remote_branches = project
+        .update(cx, |project, cx| project.branches(root_path.clone(), cx))
+        .await
+        .unwrap();
+
+    let new_branch = branches[2];
+
+    let remote_branches = remote_branches
+        .into_iter()
+        .map(|branch| branch.name)
+        .collect::<Vec<_>>();
+
+    assert_eq!(&remote_branches, &branches);
+
+    cx.update(|cx| {
+        project.update(cx, |project, cx| {
+            project.update_or_create_branch(root_path.clone(), new_branch.to_string(), cx)
+        })
+    })
+    .await
+    .unwrap();
+
+    cx.run_until_parked();
+
+    let server_branch = server_cx.update(|cx| {
+        headless_project.update(cx, |headless_project, cx| {
+            headless_project
+                .worktree_store
+                .update(cx, |worktree_store, cx| {
+                    worktree_store
+                        .current_branch(root_path.clone(), cx)
+                        .unwrap()
+                })
+        })
+    });
+
+    assert_eq!(server_branch.as_ref(), branches[2]);
+
+    // Also try creating a new branch
+    cx.update(|cx| {
+        project.update(cx, |project, cx| {
+            project.update_or_create_branch(root_path.clone(), "totally-new-branch".to_string(), cx)
+        })
+    })
+    .await
+    .unwrap();
+
+    cx.run_until_parked();
+
+    let server_branch = server_cx.update(|cx| {
+        headless_project.update(cx, |headless_project, cx| {
+            headless_project
+                .worktree_store
+                .update(cx, |worktree_store, cx| {
+                    worktree_store.current_branch(root_path, cx).unwrap()
+                })
+        })
+    });
+
+    assert_eq!(server_branch.as_ref(), "totally-new-branch");
+}
+
+pub async fn init_test(
+    server_fs: &Arc<FakeFs>,
+    cx: &mut TestAppContext,
+    server_cx: &mut TestAppContext,
+) -> (Model<Project>, Model<HeadlessProject>) {
+    let server_fs = server_fs.clone();
+    init_logger();
+
+    let (opts, ssh_server_client) = SshRemoteClient::fake_server(cx, server_cx);
     let http_client = Arc::new(BlockedHttpClient);
     let node_runtime = NodeRuntime::unavailable();
     let languages = Arc::new(LanguageRegistry::new(cx.executor()));
+    server_cx.update(HeadlessProject::init);
     let headless = server_cx.new_model(|cx| {
         client::init_settings(cx);
 
         HeadlessProject::new(
             crate::HeadlessAppState {
                 session: ssh_server_client,
-                fs: fs.clone(),
+                fs: server_fs.clone(),
                 http_client,
                 node_runtime,
                 languages,
@@ -752,13 +979,21 @@ async fn init_test(
             |_, cx| cx.on_release(|_, _| drop(headless))
         })
         .detach();
-    (project, headless, fs)
+    (project, headless)
+}
+
+fn init_logger() {
+    if std::env::var("RUST_LOG").is_ok() {
+        env_logger::try_init().ok();
+    }
 }
 
 fn build_project(ssh: Model<SshRemoteClient>, cx: &mut TestAppContext) -> Model<Project> {
     cx.update(|cx| {
-        let settings_store = SettingsStore::test(cx);
-        cx.set_global(settings_store);
+        if !cx.has_global::<SettingsStore>() {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+        }
     });
 
     let client = cx.update(|cx| {
@@ -773,6 +1008,7 @@ fn build_project(ssh: Model<SshRemoteClient>, cx: &mut TestAppContext) -> Model<
     let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
     let languages = Arc::new(LanguageRegistry::test(cx.executor()));
     let fs = FakeFs::new(cx.executor());
+
     cx.update(|cx| {
         Project::init(&client, cx);
         language::init(cx);

crates/rpc/src/proto_client.rs 🔗

@@ -123,7 +123,6 @@ impl ProtoMessageHandlerSet {
             let extract_entity_id = *this.entity_id_extractors.get(&payload_type_id)?;
             let entity_type_id = *this.entity_types_by_message_type.get(&payload_type_id)?;
             let entity_id = (extract_entity_id)(message.as_ref());
-
             match this
                 .entities_by_type_and_remote_id
                 .get_mut(&(entity_type_id, entity_id))?
@@ -145,6 +144,26 @@ pub enum EntityMessageSubscriber {
     Pending(Vec<Box<dyn AnyTypedEnvelope>>),
 }
 
+impl std::fmt::Debug for EntityMessageSubscriber {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            EntityMessageSubscriber::Entity { handle } => f
+                .debug_struct("EntityMessageSubscriber::Entity")
+                .field("handle", handle)
+                .finish(),
+            EntityMessageSubscriber::Pending(vec) => f
+                .debug_struct("EntityMessageSubscriber::Pending")
+                .field(
+                    "envelopes",
+                    &vec.iter()
+                        .map(|envelope| envelope.payload_type_name())
+                        .collect::<Vec<_>>(),
+                )
+                .finish(),
+        }
+    }
+}
+
 impl<T> From<Arc<T>> for AnyProtoClient
 where
     T: ProtoClient + 'static,

crates/settings/src/settings_store.rs 🔗

@@ -61,6 +61,7 @@ pub trait Settings: 'static + Send + Sync {
         anyhow::anyhow!("missing default")
     }
 
+    #[track_caller]
     fn register(cx: &mut AppContext)
     where
         Self: Sized,
@@ -271,6 +272,7 @@ impl SettingsStore {
     pub fn register_setting<T: Settings>(&mut self, cx: &mut AppContext) {
         let setting_type_id = TypeId::of::<T>();
         let entry = self.setting_values.entry(setting_type_id);
+
         if matches!(entry, hash_map::Entry::Occupied(_)) {
             return;
         }

crates/title_bar/src/title_bar.rs 🔗

@@ -447,7 +447,7 @@ impl TitleBar {
                 })
                 .on_click(move |_, cx| {
                     let _ = workspace.update(cx, |this, cx| {
-                        BranchList::open(this, &Default::default(), cx)
+                        BranchList::open(this, &Default::default(), cx);
                     });
                 }),
         )

crates/util/src/arc_cow.rs 🔗

@@ -75,6 +75,12 @@ impl From<String> for ArcCow<'_, str> {
     }
 }
 
+impl From<&String> for ArcCow<'_, str> {
+    fn from(value: &String) -> Self {
+        Self::Owned(value.clone().into())
+    }
+}
+
 impl<'a> From<Cow<'a, str>> for ArcCow<'a, str> {
     fn from(value: Cow<'a, str>) -> Self {
         match value {

crates/vcs_menu/Cargo.toml 🔗

@@ -14,6 +14,7 @@ fuzzy.workspace = true
 git.workspace = true
 gpui.workspace = true
 picker.workspace = true
+project.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true

crates/vcs_menu/src/lib.rs 🔗

@@ -2,24 +2,23 @@ use anyhow::{Context, Result};
 use fuzzy::{StringMatch, StringMatchCandidate};
 use git::repository::Branch;
 use gpui::{
-    actions, rems, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView,
-    InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
-    Task, View, ViewContext, VisualContext, WindowContext,
+    actions, rems, AnyElement, AppContext, AsyncAppContext, DismissEvent, EventEmitter,
+    FocusHandle, FocusableView, InteractiveElement, IntoElement, ParentElement, Render,
+    SharedString, Styled, Subscription, Task, View, ViewContext, VisualContext, WindowContext,
 };
 use picker::{Picker, PickerDelegate};
+use project::ProjectPath;
 use std::{ops::Not, sync::Arc};
 use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
 use util::ResultExt;
-use workspace::notifications::NotificationId;
-use workspace::{ModalView, Toast, Workspace};
+use workspace::notifications::DetachAndPromptErr;
+use workspace::{ModalView, Workspace};
 
 actions!(branches, [OpenRecent]);
 
 pub fn init(cx: &mut AppContext) {
     cx.observe_new_views(|workspace: &mut Workspace, _| {
-        workspace.register_action(|workspace, action, cx| {
-            BranchList::open(workspace, action, cx).log_err();
-        });
+        workspace.register_action(BranchList::open);
     })
     .detach();
 }
@@ -31,6 +30,21 @@ pub struct BranchList {
 }
 
 impl BranchList {
+    pub fn open(_: &mut Workspace, _: &OpenRecent, cx: &mut ViewContext<Workspace>) {
+        let this = cx.view().clone();
+        cx.spawn(|_, mut cx| async move {
+            // Modal branch picker has a longer trailoff than a popover one.
+            let delegate = BranchListDelegate::new(this.clone(), 70, &cx).await?;
+
+            this.update(&mut cx, |workspace, cx| {
+                workspace.toggle_modal(cx, |cx| BranchList::new(delegate, 34., cx))
+            })?;
+
+            Ok(())
+        })
+        .detach_and_prompt_err("Failed to read branches", cx, |_, _| None)
+    }
+
     fn new(delegate: BranchListDelegate, rem_width: f32, cx: &mut ViewContext<Self>) -> Self {
         let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
         let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
@@ -40,17 +54,6 @@ impl BranchList {
             _subscription,
         }
     }
-    pub fn open(
-        workspace: &mut Workspace,
-        _: &OpenRecent,
-        cx: &mut ViewContext<Workspace>,
-    ) -> Result<()> {
-        // Modal branch picker has a longer trailoff than a popover one.
-        let delegate = BranchListDelegate::new(workspace, cx.view().clone(), 70, cx)?;
-        workspace.toggle_modal(cx, |cx| BranchList::new(delegate, 34., cx));
-
-        Ok(())
-    }
 }
 impl ModalView for BranchList {}
 impl EventEmitter<DismissEvent> for BranchList {}
@@ -100,36 +103,32 @@ pub struct BranchListDelegate {
 }
 
 impl BranchListDelegate {
-    fn new(
-        workspace: &Workspace,
-        handle: View<Workspace>,
+    async fn new(
+        workspace: View<Workspace>,
         branch_name_trailoff_after: usize,
-        cx: &AppContext,
+        cx: &AsyncAppContext,
     ) -> Result<Self> {
-        let project = workspace.project().read(cx);
-        let repo = project
-            .get_first_worktree_root_repo(cx)
-            .context("failed to get root repository for first worktree")?;
+        let all_branches_request = cx.update(|cx| {
+            let project = workspace.read(cx).project().read(cx);
+            let first_worktree = project
+                .visible_worktrees(cx)
+                .next()
+                .context("No worktrees found")?;
+            let project_path = ProjectPath::root_path(first_worktree.read(cx).id());
+            anyhow::Ok(project.branches(project_path, cx))
+        })??;
+
+        let all_branches = all_branches_request.await?;
 
-        let all_branches = repo.branches()?;
         Ok(Self {
             matches: vec![],
-            workspace: handle,
+            workspace,
             all_branches,
             selected_index: 0,
             last_query: Default::default(),
             branch_name_trailoff_after,
         })
     }
-
-    fn display_error_toast(&self, message: String, cx: &mut WindowContext<'_>) {
-        self.workspace.update(cx, |model, ctx| {
-            struct GitCheckoutFailure;
-            let id = NotificationId::unique::<GitCheckoutFailure>();
-
-            model.show_toast(Toast::new(id, message), ctx)
-        });
-    }
 }
 
 impl PickerDelegate for BranchListDelegate {
@@ -235,40 +234,32 @@ impl PickerDelegate for BranchListDelegate {
         cx.spawn({
             let branch = branch.clone();
             |picker, mut cx| async move {
-                picker
-                    .update(&mut cx, |this, cx| {
-                        let project = this.delegate.workspace.read(cx).project().read(cx);
-                        let repo = project
-                            .get_first_worktree_root_repo(cx)
-                            .context("failed to get root repository for first worktree")?;
-
-                        let branch_to_checkout = match branch {
-                            BranchEntry::Branch(branch) => branch.string,
-                            BranchEntry::NewBranch { name: branch_name } => {
-                                let status = repo.create_branch(&branch_name);
-                                if status.is_err() {
-                                    this.delegate.display_error_toast(format!("Failed to create branch '{branch_name}', check for conflicts or unstashed files"), cx);
-                                    status?;
-                                }
-
-                                branch_name
-                            }
-                        };
-
-                        let status = repo.change_branch(&branch_to_checkout);
-                        if status.is_err() {
-                            this.delegate.display_error_toast(format!("Failed to checkout branch '{branch_to_checkout}', check for conflicts or unstashed files"), cx);
-                            status?;
-                        }
+                let branch_change_task = picker.update(&mut cx, |this, cx| {
+                    let project = this.delegate.workspace.read(cx).project().read(cx);
 
-                        cx.emit(DismissEvent);
+                    let branch_to_checkout = match branch {
+                        BranchEntry::Branch(branch) => branch.string,
+                        BranchEntry::NewBranch { name: branch_name } => branch_name,
+                    };
+                    let worktree = project
+                        .worktrees(cx)
+                        .next()
+                        .context("worktree disappeared")?;
+                    let repository = ProjectPath::root_path(worktree.read(cx).id());
 
-                        Ok::<(), anyhow::Error>(())
-                    })
-                    .log_err();
+                    anyhow::Ok(project.update_or_create_branch(repository, branch_to_checkout, cx))
+                })??;
+
+                branch_change_task.await?;
+
+                picker.update(&mut cx, |_, cx| {
+                    cx.emit(DismissEvent);
+
+                    Ok::<(), anyhow::Error>(())
+                })
             }
         })
-        .detach();
+        .detach_and_prompt_err("Failed to change branch", cx, |_, _| None);
     }
 
     fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {

crates/worktree/src/worktree.rs 🔗

@@ -2385,6 +2385,12 @@ impl Snapshot {
             .map(|entry| entry.to_owned())
     }
 
+    pub fn git_entry(&self, work_directory_path: Arc<Path>) -> Option<RepositoryEntry> {
+        self.repository_entries
+            .get(&RepositoryWorkDirectory(work_directory_path))
+            .map(|entry| entry.to_owned())
+    }
+
     pub fn git_entries(&self) -> impl Iterator<Item = &RepositoryEntry> {
         self.repository_entries.values()
     }