git: Add git worktree picker (#38719)

Alvaro Parker and Cole Miller created

Related discussions #26084 

Worktree creations are implemented similar to how branch creations are
handled on the branch picker (the user types a new name that's not on
the list and a new entry option appears to create a new branch with that
name).


https://github.com/user-attachments/assets/39e58983-740c-4a91-be88-57ef95aed85b

With this picker you have a few workflows: 

- Open the picker and type the name of a branch that's checked out on an
existing worktree:
    - Press enter to open the worktree on a new window
- Press ctrl-enter to open the worktree and replace the current window
- Open the picker and type the name of a new branch or an existing one
that's not checked out in another worktree:
- Press enter to create the worktree and open in a new window. If the
branch doesn't exists, we will create a new one based on the branch you
have currently checked out. If the branch does exists then we create a
worktree with that branch checked out.
- Press ctrl-enter to do everything on the previous point but instead,
replace the current window with the new worktre.
- Open the picker and type the name of a new branch or an existing one
that's not checked out in another worktree:
- If a default branch is detected on the repo, you can create a new
worktree based on that branch by pressing ctrl-enter or
ctrl-shift-enter. The first one will open a new window and the last one
will replace the current one.


Note: If you preffer to not use the system prompt for choosing a
directory, you can set `"use_system_path_prompts": false` in zed
settings.

Release Notes:

- Added git worktree picker to open a git worktree on a new window or
replace the current one
- Added git worktree creation action

---------

Co-authored-by: Cole Miller <cole@zed.dev>

Change summary

Cargo.lock                                       |   2 
assets/keymaps/default-linux.json                |   8 
assets/keymaps/default-macos.json                |   8 
assets/keymaps/default-windows.json              |   8 
crates/fs/src/fake_git_repo.rs                   |  15 
crates/git/src/repository.rs                     | 113 ++
crates/git_ui/Cargo.toml                         |   2 
crates/git_ui/src/git_ui.rs                      |   2 
crates/git_ui/src/worktree_picker.rs             | 743 ++++++++++++++++++
crates/project/src/git_store.rs                  | 119 ++
crates/proto/proto/git.proto                     |  23 
crates/proto/proto/zed.proto                     |   6 
crates/proto/src/proto.rs                        |   7 
crates/recent_projects/src/recent_projects.rs    |   2 
crates/recent_projects/src/remote_connections.rs |   4 
crates/zed_actions/src/lib.rs                    |   4 
16 files changed, 1,059 insertions(+), 7 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -7116,6 +7116,8 @@ dependencies = [
  "picker",
  "pretty_assertions",
  "project",
+ "recent_projects",
+ "remote",
  "schemars 1.0.4",
  "serde",
  "serde_json",

assets/keymaps/default-linux.json πŸ”—

@@ -1255,6 +1255,14 @@
       "ctrl-shift-enter": "workspace::OpenWithSystem"
     }
   },
+  {
+    "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
+      "ctrl-space": "git::WorktreeFromDefault"
+    }
+  },
   {
     "context": "SettingsWindow",
     "use_key_equivalents": true,

assets/keymaps/default-macos.json πŸ”—

@@ -1360,6 +1360,14 @@
       "ctrl-shift-enter": "workspace::OpenWithSystem"
     }
   },
+  {
+    "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
+      "ctrl-space": "git::WorktreeFromDefault"
+    }
+  },
   {
     "context": "SettingsWindow",
     "use_key_equivalents": true,

assets/keymaps/default-windows.json πŸ”—

@@ -1282,6 +1282,14 @@
       "shift-alt-a": "onboarding::OpenAccount"
     }
   },
+  {
+    "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
+      "ctrl-space": "git::WorktreeFromDefault"
+    }
+  },
   {
     "context": "SettingsWindow",
     "use_key_equivalents": true,

crates/fs/src/fake_git_repo.rs πŸ”—

@@ -7,7 +7,7 @@ use git::{
     blame::Blame,
     repository::{
         AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository,
-        GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode,
+        GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode, Worktree,
     },
     status::{
         DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus,
@@ -387,6 +387,19 @@ impl GitRepository for FakeGitRepository {
         })
     }
 
+    fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
+        unimplemented!()
+    }
+
+    fn create_worktree(
+        &self,
+        _: String,
+        _: PathBuf,
+        _: Option<String>,
+    ) -> BoxFuture<'_, Result<()>> {
+        unimplemented!()
+    }
+
     fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
         self.with_state_async(true, |state| {
             state.current_branch_name = Some(name);

crates/git/src/repository.rs πŸ”—

@@ -72,6 +72,50 @@ impl Branch {
     }
 }
 
+#[derive(Clone, Debug, Hash, PartialEq, Eq)]
+pub struct Worktree {
+    pub path: PathBuf,
+    pub ref_name: SharedString,
+    pub sha: SharedString,
+}
+
+impl Worktree {
+    pub fn branch(&self) -> &str {
+        self.ref_name
+            .as_ref()
+            .strip_prefix("refs/heads/")
+            .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
+            .unwrap_or(self.ref_name.as_ref())
+    }
+}
+
+pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree> {
+    let mut worktrees = Vec::new();
+    let entries = raw_worktrees.as_ref().split("\n\n");
+    for entry in entries {
+        let mut parts = entry.splitn(3, '\n');
+        let path = parts
+            .next()
+            .and_then(|p| p.split_once(' ').map(|(_, path)| path.to_string()));
+        let sha = parts
+            .next()
+            .and_then(|p| p.split_once(' ').map(|(_, sha)| sha.to_string()));
+        let ref_name = parts
+            .next()
+            .and_then(|p| p.split_once(' ').map(|(_, ref_name)| ref_name.to_string()));
+
+        if let (Some(path), Some(sha), Some(ref_name)) = (path, sha, ref_name) {
+            worktrees.push(Worktree {
+                path: PathBuf::from(path),
+                ref_name: ref_name.into(),
+                sha: sha.into(),
+            })
+        }
+    }
+
+    worktrees
+}
+
 #[derive(Clone, Debug, Hash, PartialEq, Eq)]
 pub struct Upstream {
     pub ref_name: SharedString,
@@ -390,6 +434,15 @@ pub trait GitRepository: Send + Sync {
     fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
     fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
 
+    fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
+
+    fn create_worktree(
+        &self,
+        name: String,
+        directory: PathBuf,
+        from_commit: Option<String>,
+    ) -> BoxFuture<'_, Result<()>>;
+
     fn reset(
         &self,
         commit: String,
@@ -1206,6 +1259,66 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
+    fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
+        let git_binary_path = self.any_git_binary_path.clone();
+        let working_directory = self.working_directory();
+        self.executor
+            .spawn(async move {
+                let output = new_smol_command(&git_binary_path)
+                    .current_dir(working_directory?)
+                    .args(&["--no-optional-locks", "worktree", "list", "--porcelain"])
+                    .output()
+                    .await?;
+                if output.status.success() {
+                    let stdout = String::from_utf8_lossy(&output.stdout);
+                    Ok(parse_worktrees_from_str(&stdout))
+                } else {
+                    let stderr = String::from_utf8_lossy(&output.stderr);
+                    anyhow::bail!("git worktree list failed: {stderr}");
+                }
+            })
+            .boxed()
+    }
+
+    fn create_worktree(
+        &self,
+        name: String,
+        directory: PathBuf,
+        from_commit: Option<String>,
+    ) -> BoxFuture<'_, Result<()>> {
+        let git_binary_path = self.any_git_binary_path.clone();
+        let working_directory = self.working_directory();
+        let final_path = directory.join(&name);
+        let mut args = vec![
+            OsString::from("--no-optional-locks"),
+            OsString::from("worktree"),
+            OsString::from("add"),
+            OsString::from(final_path.as_os_str()),
+        ];
+        if let Some(from_commit) = from_commit {
+            args.extend([
+                OsString::from("-b"),
+                OsString::from(name.as_str()),
+                OsString::from(from_commit),
+            ]);
+        }
+        self.executor
+            .spawn(async move {
+                let output = new_smol_command(&git_binary_path)
+                    .current_dir(working_directory?)
+                    .args(args)
+                    .output()
+                    .await?;
+                if output.status.success() {
+                    Ok(())
+                } else {
+                    let stderr = String::from_utf8_lossy(&output.stderr);
+                    anyhow::bail!("git worktree list failed: {stderr}");
+                }
+            })
+            .boxed()
+    }
+
     fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
         let repo = self.repository.clone();
         let working_directory = self.working_directory();

crates/git_ui/Cargo.toml πŸ”—

@@ -45,6 +45,8 @@ notifications.workspace = true
 panel.workspace = true
 picker.workspace = true
 project.workspace = true
+recent_projects.workspace = true
+remote.workspace = true
 schemars.workspace = true
 serde.workspace = true
 serde_json.workspace = true

crates/git_ui/src/git_ui.rs πŸ”—

@@ -46,6 +46,7 @@ pub(crate) mod remote_output;
 pub mod repository_selector;
 pub mod stash_picker;
 pub mod text_diff_view;
+pub mod worktree_picker;
 
 actions!(
     git,
@@ -72,6 +73,7 @@ pub fn init(cx: &mut App) {
         git_panel::register(workspace);
         repository_selector::register(workspace);
         branch_picker::register(workspace);
+        worktree_picker::register(workspace);
         stash_picker::register(workspace);
 
         let project = workspace.project().read(cx);

crates/git_ui/src/worktree_picker.rs πŸ”—

@@ -0,0 +1,743 @@
+use anyhow::Context as _;
+use fuzzy::StringMatchCandidate;
+
+use git::repository::Worktree as GitWorktree;
+use gpui::{
+    Action, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement,
+    PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window,
+    actions, rems,
+};
+use picker::{Picker, PickerDelegate, PickerEditorPosition};
+use project::{DirectoryLister, git_store::Repository};
+use recent_projects::{RemoteConnectionModal, connect};
+use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
+use std::{path::PathBuf, sync::Arc};
+use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
+use util::ResultExt;
+use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr};
+
+actions!(git, [WorktreeFromDefault, WorktreeFromDefaultOnWindow]);
+
+pub fn register(workspace: &mut Workspace) {
+    workspace.register_action(open);
+}
+
+pub fn open(
+    workspace: &mut Workspace,
+    _: &zed_actions::git::Worktree,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    let repository = workspace.project().read(cx).active_repository(cx);
+    let workspace_handle = workspace.weak_handle();
+    workspace.toggle_modal(window, cx, |window, cx| {
+        WorktreeList::new(repository, workspace_handle, rems(34.), window, cx)
+    })
+}
+
+pub struct WorktreeList {
+    width: Rems,
+    pub picker: Entity<Picker<WorktreeListDelegate>>,
+    picker_focus_handle: FocusHandle,
+    _subscription: Subscription,
+}
+
+impl WorktreeList {
+    fn new(
+        repository: Option<Entity<Repository>>,
+        workspace: WeakEntity<Workspace>,
+        width: Rems,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let all_worktrees_request = repository
+            .clone()
+            .map(|repository| repository.update(cx, |repository, _| repository.worktrees()));
+
+        let default_branch_request = repository
+            .clone()
+            .map(|repository| repository.update(cx, |repository, _| repository.default_branch()));
+
+        cx.spawn_in(window, async move |this, cx| {
+            let all_worktrees = all_worktrees_request
+                .context("No active repository")?
+                .await??;
+
+            let default_branch = default_branch_request
+                .context("No active repository")?
+                .await
+                .map(Result::ok)
+                .ok()
+                .flatten()
+                .flatten();
+
+            this.update_in(cx, |this, window, cx| {
+                this.picker.update(cx, |picker, cx| {
+                    picker.delegate.all_worktrees = Some(all_worktrees);
+                    picker.delegate.default_branch = default_branch;
+                    picker.refresh(window, cx);
+                })
+            })?;
+
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+
+        let delegate = WorktreeListDelegate::new(workspace, repository, window, cx);
+        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
+        let picker_focus_handle = picker.focus_handle(cx);
+        picker.update(cx, |picker, _| {
+            picker.delegate.focus_handle = picker_focus_handle.clone();
+        });
+
+        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
+            cx.emit(DismissEvent);
+        });
+
+        Self {
+            picker,
+            picker_focus_handle,
+            width,
+            _subscription,
+        }
+    }
+
+    fn handle_modifiers_changed(
+        &mut self,
+        ev: &ModifiersChangedEvent,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.picker
+            .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
+    }
+
+    fn handle_new_worktree(
+        &mut self,
+        replace_current_window: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.picker.update(cx, |picker, cx| {
+            let ix = picker.delegate.selected_index();
+            let Some(entry) = picker.delegate.matches.get(ix) else {
+                return;
+            };
+            let Some(default_branch) = picker.delegate.default_branch.clone() else {
+                return;
+            };
+            if !entry.is_new {
+                return;
+            }
+            picker.delegate.create_worktree(
+                entry.worktree.branch(),
+                replace_current_window,
+                Some(default_branch.into()),
+                window,
+                cx,
+            );
+        })
+    }
+}
+impl ModalView for WorktreeList {}
+impl EventEmitter<DismissEvent> for WorktreeList {}
+
+impl Focusable for WorktreeList {
+    fn focus_handle(&self, _: &App) -> FocusHandle {
+        self.picker_focus_handle.clone()
+    }
+}
+
+impl Render for WorktreeList {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex()
+            .key_context("GitWorktreeSelector")
+            .w(self.width)
+            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
+            .on_action(cx.listener(|this, _: &WorktreeFromDefault, w, cx| {
+                this.handle_new_worktree(false, w, cx)
+            }))
+            .on_action(cx.listener(|this, _: &WorktreeFromDefaultOnWindow, w, cx| {
+                this.handle_new_worktree(true, w, cx)
+            }))
+            .child(self.picker.clone())
+            .on_mouse_down_out({
+                cx.listener(move |this, _, window, cx| {
+                    this.picker.update(cx, |this, cx| {
+                        this.cancel(&Default::default(), window, cx);
+                    })
+                })
+            })
+    }
+}
+
+#[derive(Debug, Clone)]
+struct WorktreeEntry {
+    worktree: GitWorktree,
+    positions: Vec<usize>,
+    is_new: bool,
+}
+
+pub struct WorktreeListDelegate {
+    matches: Vec<WorktreeEntry>,
+    all_worktrees: Option<Vec<GitWorktree>>,
+    workspace: WeakEntity<Workspace>,
+    repo: Option<Entity<Repository>>,
+    selected_index: usize,
+    last_query: String,
+    modifiers: Modifiers,
+    focus_handle: FocusHandle,
+    default_branch: Option<SharedString>,
+}
+
+impl WorktreeListDelegate {
+    fn new(
+        workspace: WeakEntity<Workspace>,
+        repo: Option<Entity<Repository>>,
+        _window: &mut Window,
+        cx: &mut Context<WorktreeList>,
+    ) -> Self {
+        Self {
+            matches: vec![],
+            all_worktrees: None,
+            workspace,
+            selected_index: 0,
+            repo,
+            last_query: Default::default(),
+            modifiers: Default::default(),
+            focus_handle: cx.focus_handle(),
+            default_branch: None,
+        }
+    }
+
+    fn create_worktree(
+        &self,
+        worktree_branch: &str,
+        replace_current_window: bool,
+        commit: Option<String>,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) {
+        let workspace = self.workspace.clone();
+        let Some(repo) = self.repo.clone() else {
+            return;
+        };
+
+        let worktree_path = self
+            .workspace
+            .clone()
+            .update(cx, |this, cx| {
+                this.prompt_for_open_path(
+                    PathPromptOptions {
+                        files: false,
+                        directories: true,
+                        multiple: false,
+                        prompt: Some("Select directory for new worktree".into()),
+                    },
+                    DirectoryLister::Project(this.project().clone()),
+                    window,
+                    cx,
+                )
+            })
+            .log_err();
+        let Some(worktree_path) = worktree_path else {
+            return;
+        };
+
+        let branch = worktree_branch.to_string();
+        let window_handle = window.window_handle();
+        cx.spawn_in(window, async move |_, cx| {
+            let Some(paths) = worktree_path.await? else {
+                return anyhow::Ok(());
+            };
+            let path = paths.get(0).cloned().context("No path selected")?;
+
+            repo.update(cx, |repo, _| {
+                repo.create_worktree(branch.clone(), path.clone(), commit)
+            })?
+            .await??;
+
+            let final_path = path.join(branch);
+
+            let (connection_options, app_state, is_local) =
+                workspace.update(cx, |workspace, cx| {
+                    let project = workspace.project().clone();
+                    let connection_options = project.read(cx).remote_connection_options(cx);
+                    let app_state = workspace.app_state().clone();
+                    let is_local = project.read(cx).is_local();
+                    (connection_options, app_state, is_local)
+                })?;
+
+            if is_local {
+                workspace
+                    .update_in(cx, |workspace, window, cx| {
+                        workspace.open_workspace_for_paths(
+                            replace_current_window,
+                            vec![final_path],
+                            window,
+                            cx,
+                        )
+                    })?
+                    .await?;
+            } else if let Some(connection_options) = connection_options {
+                open_remote_worktree(
+                    connection_options,
+                    vec![final_path],
+                    app_state,
+                    window_handle,
+                    replace_current_window,
+                    cx,
+                )
+                .await?;
+            }
+
+            anyhow::Ok(())
+        })
+        .detach_and_prompt_err("Failed to create worktree", window, cx, |e, _, _| {
+            Some(e.to_string())
+        });
+    }
+
+    fn open_worktree(
+        &self,
+        worktree_path: &PathBuf,
+        replace_current_window: bool,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) {
+        let workspace = self.workspace.clone();
+        let path = worktree_path.clone();
+
+        let Some((connection_options, app_state, is_local)) = workspace
+            .update(cx, |workspace, cx| {
+                let project = workspace.project().clone();
+                let connection_options = project.read(cx).remote_connection_options(cx);
+                let app_state = workspace.app_state().clone();
+                let is_local = project.read(cx).is_local();
+                (connection_options, app_state, is_local)
+            })
+            .log_err()
+        else {
+            return;
+        };
+
+        if is_local {
+            let open_task = workspace.update(cx, |workspace, cx| {
+                workspace.open_workspace_for_paths(replace_current_window, vec![path], window, cx)
+            });
+            cx.spawn(async move |_, _| {
+                open_task?.await?;
+                anyhow::Ok(())
+            })
+            .detach_and_prompt_err(
+                "Failed to open worktree",
+                window,
+                cx,
+                |e, _, _| Some(e.to_string()),
+            );
+        } else if let Some(connection_options) = connection_options {
+            let window_handle = window.window_handle();
+            cx.spawn_in(window, async move |_, cx| {
+                open_remote_worktree(
+                    connection_options,
+                    vec![path],
+                    app_state,
+                    window_handle,
+                    replace_current_window,
+                    cx,
+                )
+                .await
+            })
+            .detach_and_prompt_err(
+                "Failed to open worktree",
+                window,
+                cx,
+                |e, _, _| Some(e.to_string()),
+            );
+        }
+
+        cx.emit(DismissEvent);
+    }
+
+    fn base_branch<'a>(&'a self, cx: &'a mut Context<Picker<Self>>) -> Option<&'a str> {
+        self.repo
+            .as_ref()
+            .and_then(|repo| repo.read(cx).branch.as_ref().map(|b| b.name()))
+    }
+}
+
+async fn open_remote_worktree(
+    connection_options: RemoteConnectionOptions,
+    paths: Vec<PathBuf>,
+    app_state: Arc<workspace::AppState>,
+    window: gpui::AnyWindowHandle,
+    replace_current_window: bool,
+    cx: &mut AsyncApp,
+) -> anyhow::Result<()> {
+    let workspace_window = window
+        .downcast::<Workspace>()
+        .ok_or_else(|| anyhow::anyhow!("Window is not a Workspace window"))?;
+
+    let connect_task = workspace_window.update(cx, |workspace, window, cx| {
+        workspace.toggle_modal(window, cx, |window, cx| {
+            RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx)
+        });
+
+        let prompt = workspace
+            .active_modal::<RemoteConnectionModal>(cx)
+            .expect("Modal just created")
+            .read(cx)
+            .prompt
+            .clone();
+
+        connect(
+            ConnectionIdentifier::setup(),
+            connection_options.clone(),
+            prompt,
+            window,
+            cx,
+        )
+        .prompt_err("Failed to connect", window, cx, |_, _, _| None)
+    })?;
+
+    let session = connect_task.await;
+
+    workspace_window.update(cx, |workspace, _window, cx| {
+        if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
+            prompt.update(cx, |prompt, cx| prompt.finished(cx))
+        }
+    })?;
+
+    let Some(Some(session)) = session else {
+        return Ok(());
+    };
+
+    let new_project = cx.update(|cx| {
+        project::Project::remote(
+            session,
+            app_state.client.clone(),
+            app_state.node_runtime.clone(),
+            app_state.user_store.clone(),
+            app_state.languages.clone(),
+            app_state.fs.clone(),
+            cx,
+        )
+    })?;
+
+    let window_to_use = if replace_current_window {
+        workspace_window
+    } else {
+        let workspace_position = cx
+            .update(|cx| {
+                workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
+            })?
+            .await
+            .context("fetching workspace position from db")?;
+
+        let mut options =
+            cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx))?;
+        options.window_bounds = workspace_position.window_bounds;
+
+        cx.open_window(options, |window, cx| {
+            cx.new(|cx| {
+                let mut workspace =
+                    Workspace::new(None, new_project.clone(), app_state.clone(), window, cx);
+                workspace.centered_layout = workspace_position.centered_layout;
+                workspace
+            })
+        })?
+    };
+
+    workspace::open_remote_project_with_existing_connection(
+        connection_options,
+        new_project,
+        paths,
+        app_state,
+        window_to_use,
+        cx,
+    )
+    .await?;
+
+    Ok(())
+}
+
+impl PickerDelegate for WorktreeListDelegate {
+    type ListItem = ListItem;
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+        "Select worktree…".into()
+    }
+
+    fn editor_position(&self) -> PickerEditorPosition {
+        PickerEditorPosition::Start
+    }
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(
+        &mut self,
+        ix: usize,
+        _window: &mut Window,
+        _: &mut Context<Picker<Self>>,
+    ) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Task<()> {
+        let Some(all_worktrees) = self.all_worktrees.clone() else {
+            return Task::ready(());
+        };
+
+        cx.spawn_in(window, async move |picker, cx| {
+            let mut matches: Vec<WorktreeEntry> = if query.is_empty() {
+                all_worktrees
+                    .into_iter()
+                    .map(|worktree| WorktreeEntry {
+                        worktree,
+                        positions: Vec::new(),
+                        is_new: false,
+                    })
+                    .collect()
+            } else {
+                let candidates = all_worktrees
+                    .iter()
+                    .enumerate()
+                    .map(|(ix, worktree)| StringMatchCandidate::new(ix, worktree.branch()))
+                    .collect::<Vec<StringMatchCandidate>>();
+                fuzzy::match_strings(
+                    &candidates,
+                    &query,
+                    true,
+                    true,
+                    10000,
+                    &Default::default(),
+                    cx.background_executor().clone(),
+                )
+                .await
+                .into_iter()
+                .map(|candidate| WorktreeEntry {
+                    worktree: all_worktrees[candidate.candidate_id].clone(),
+                    positions: candidate.positions,
+                    is_new: false,
+                })
+                .collect()
+            };
+            picker
+                .update(cx, |picker, _| {
+                    if !query.is_empty()
+                        && !matches
+                            .first()
+                            .is_some_and(|entry| entry.worktree.branch() == query)
+                    {
+                        let query = query.replace(' ', "-");
+                        matches.push(WorktreeEntry {
+                            worktree: GitWorktree {
+                                path: Default::default(),
+                                ref_name: format!("refs/heads/{query}").into(),
+                                sha: Default::default(),
+                            },
+                            positions: Vec::new(),
+                            is_new: true,
+                        })
+                    }
+                    let delegate = &mut picker.delegate;
+                    delegate.matches = matches;
+                    if delegate.matches.is_empty() {
+                        delegate.selected_index = 0;
+                    } else {
+                        delegate.selected_index =
+                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
+                    }
+                    delegate.last_query = query;
+                })
+                .log_err();
+        })
+    }
+
+    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        let Some(entry) = self.matches.get(self.selected_index()) else {
+            return;
+        };
+        if entry.is_new {
+            self.create_worktree(&entry.worktree.branch(), secondary, None, window, cx);
+        } else {
+            self.open_worktree(&entry.worktree.path, secondary, window, cx);
+        }
+
+        cx.emit(DismissEvent);
+    }
+
+    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
+        cx.emit(DismissEvent);
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let entry = &self.matches.get(ix)?;
+        let path = entry.worktree.path.to_string_lossy().to_string();
+        let sha = entry
+            .worktree
+            .sha
+            .clone()
+            .chars()
+            .take(7)
+            .collect::<String>();
+
+        let focus_handle = self.focus_handle.clone();
+        let icon = if let Some(default_branch) = self.default_branch.clone()
+            && entry.is_new
+        {
+            Some(
+                IconButton::new("worktree-from-default", IconName::GitBranchAlt)
+                    .on_click(|_, window, cx| {
+                        window.dispatch_action(WorktreeFromDefault.boxed_clone(), cx)
+                    })
+                    .on_right_click(|_, window, cx| {
+                        window.dispatch_action(WorktreeFromDefaultOnWindow.boxed_clone(), cx)
+                    })
+                    .tooltip(move |_, cx| {
+                        Tooltip::for_action_in(
+                            format!("From default branch {default_branch}"),
+                            &WorktreeFromDefault,
+                            &focus_handle,
+                            cx,
+                        )
+                    }),
+            )
+        } else {
+            None
+        };
+
+        let branch_name = if entry.is_new {
+            h_flex()
+                .gap_1()
+                .child(
+                    Icon::new(IconName::Plus)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
+                .child(
+                    Label::new(format!("Create worktree \"{}\"…", entry.worktree.branch()))
+                        .single_line()
+                        .truncate(),
+                )
+                .into_any_element()
+        } else {
+            h_flex()
+                .gap_1()
+                .child(
+                    Icon::new(IconName::GitBranch)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
+                .child(HighlightedLabel::new(
+                    entry.worktree.branch().to_owned(),
+                    entry.positions.clone(),
+                ))
+                .truncate()
+                .into_any_element()
+        };
+
+        let sublabel = if entry.is_new {
+            format!(
+                "based off {}",
+                self.base_branch(cx).unwrap_or("the current branch")
+            )
+        } else {
+            format!("at {}", path)
+        };
+
+        Some(
+            ListItem::new(SharedString::from(format!("worktree-menu-{ix}")))
+                .inset(true)
+                .spacing(ListItemSpacing::Sparse)
+                .toggle_state(selected)
+                .child(
+                    v_flex()
+                        .w_full()
+                        .overflow_hidden()
+                        .child(
+                            h_flex()
+                                .gap_6()
+                                .justify_between()
+                                .overflow_x_hidden()
+                                .child(branch_name)
+                                .when(!entry.is_new, |el| {
+                                    el.child(
+                                        Label::new(sha)
+                                            .size(LabelSize::Small)
+                                            .color(Color::Muted)
+                                            .into_element(),
+                                    )
+                                }),
+                        )
+                        .child(
+                            div().max_w_96().child(
+                                Label::new(sublabel)
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted)
+                                    .truncate()
+                                    .into_any_element(),
+                            ),
+                        ),
+                )
+                .end_slot::<IconButton>(icon),
+        )
+    }
+
+    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
+        Some("No worktrees found".into())
+    }
+
+    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
+        let focus_handle = self.focus_handle.clone();
+
+        Some(
+            h_flex()
+                .w_full()
+                .p_1p5()
+                .gap_0p5()
+                .justify_end()
+                .border_t_1()
+                .border_color(cx.theme().colors().border_variant)
+                .child(
+                    Button::new("open-in-new-window", "Open in new window")
+                        .key_binding(
+                            KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
+                                .map(|kb| kb.size(rems_from_px(12.))),
+                        )
+                        .on_click(|_, window, cx| {
+                            window.dispatch_action(menu::Confirm.boxed_clone(), cx)
+                        }),
+                )
+                .child(
+                    Button::new("open-in-window", "Open")
+                        .key_binding(
+                            KeyBinding::for_action_in(&menu::SecondaryConfirm, &focus_handle, cx)
+                                .map(|kb| kb.size(rems_from_px(12.))),
+                        )
+                        .on_click(|_, window, cx| {
+                            window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
+                        }),
+                )
+                .into_any(),
+        )
+    }
+}

crates/project/src/git_store.rs πŸ”—

@@ -27,7 +27,7 @@ use git::{
     repository::{
         Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, FetchOptions,
         GitRepository, GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath,
-        ResetMode, UpstreamTrackingStatus,
+        ResetMode, UpstreamTrackingStatus, Worktree as GitWorktree,
     },
     stash::{GitStash, StashEntry},
     status::{
@@ -445,6 +445,8 @@ impl GitStore {
         client.add_entity_message_handler(Self::handle_update_repository);
         client.add_entity_message_handler(Self::handle_remove_repository);
         client.add_entity_request_handler(Self::handle_git_clone);
+        client.add_entity_request_handler(Self::handle_get_worktrees);
+        client.add_entity_request_handler(Self::handle_create_worktree);
     }
 
     pub fn is_local(&self) -> bool {
@@ -1932,6 +1934,48 @@ impl GitStore {
         })
     }
 
+    async fn handle_get_worktrees(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitGetWorktrees>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::GitWorktreesResponse> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+
+        let worktrees = repository_handle
+            .update(&mut cx, |repository_handle, _| {
+                repository_handle.worktrees()
+            })?
+            .await??;
+
+        Ok(proto::GitWorktreesResponse {
+            worktrees: worktrees
+                .into_iter()
+                .map(|worktree| worktree_to_proto(&worktree))
+                .collect::<Vec<_>>(),
+        })
+    }
+
+    async fn handle_create_worktree(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitCreateWorktree>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+        let directory = PathBuf::from(envelope.payload.directory);
+        let name = envelope.payload.name;
+        let commit = envelope.payload.commit;
+
+        repository_handle
+            .update(&mut cx, |repository_handle, _| {
+                repository_handle.create_worktree(name, directory, commit)
+            })?
+            .await??;
+
+        Ok(proto::Ack {})
+    }
+
     async fn handle_get_branches(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::GitGetBranches>,
@@ -4422,6 +4466,63 @@ impl Repository {
         })
     }
 
+    pub fn worktrees(&mut self) -> oneshot::Receiver<Result<Vec<GitWorktree>>> {
+        let id = self.id;
+        self.send_job(None, move |repo, _| async move {
+            match repo {
+                RepositoryState::Local { backend, .. } => backend.worktrees().await,
+                RepositoryState::Remote { project_id, client } => {
+                    let response = client
+                        .request(proto::GitGetWorktrees {
+                            project_id: project_id.0,
+                            repository_id: id.to_proto(),
+                        })
+                        .await?;
+
+                    let worktrees = response
+                        .worktrees
+                        .into_iter()
+                        .map(|worktree| proto_to_worktree(&worktree))
+                        .collect();
+
+                    Ok(worktrees)
+                }
+            }
+        })
+    }
+
+    pub fn create_worktree(
+        &mut self,
+        name: String,
+        path: PathBuf,
+        commit: Option<String>,
+    ) -> oneshot::Receiver<Result<()>> {
+        let id = self.id;
+        self.send_job(
+            Some("git worktree add".into()),
+            move |repo, _cx| async move {
+                match repo {
+                    RepositoryState::Local { backend, .. } => {
+                        backend.create_worktree(name, path, commit).await
+                    }
+                    RepositoryState::Remote { project_id, client } => {
+                        client
+                            .request(proto::GitCreateWorktree {
+                                project_id: project_id.0,
+                                repository_id: id.to_proto(),
+                                name,
+                                directory: path.to_string_lossy().to_string(),
+                                commit,
+                            })
+                            .await?;
+
+                        Ok(())
+                    }
+                }
+            },
+        )
+    }
+
     pub fn default_branch(&mut self) -> oneshot::Receiver<Result<Option<SharedString>>> {
         let id = self.id;
         self.send_job(None, move |repo, _| async move {
@@ -5254,6 +5355,22 @@ fn branch_to_proto(branch: &git::repository::Branch) -> proto::Branch {
     }
 }
 
+fn worktree_to_proto(worktree: &git::repository::Worktree) -> proto::Worktree {
+    proto::Worktree {
+        path: worktree.path.to_string_lossy().to_string(),
+        ref_name: worktree.ref_name.to_string(),
+        sha: worktree.sha.to_string(),
+    }
+}
+
+fn proto_to_worktree(proto: &proto::Worktree) -> git::repository::Worktree {
+    git::repository::Worktree {
+        path: PathBuf::from(proto.path.clone()),
+        ref_name: proto.ref_name.clone().into(),
+        sha: proto.sha.clone().into(),
+    }
+}
+
 fn proto_to_branch(proto: &proto::Branch) -> git::repository::Branch {
     git::repository::Branch {
         is_head: proto.is_head,

crates/proto/proto/git.proto πŸ”—

@@ -506,3 +506,26 @@ message GetBlobContent {
 message GetBlobContentResponse {
     string content = 1;
 }
+
+message GitGetWorktrees {
+    uint64 project_id = 1;
+    uint64 repository_id = 2;
+}
+
+message GitWorktreesResponse {
+    repeated Worktree worktrees = 1;
+}
+
+message Worktree {
+    string path = 1;
+    string ref_name = 2;
+    string sha = 3;
+}
+
+message GitCreateWorktree {
+    uint64 project_id = 1;
+    uint64 repository_id = 2;
+    string name = 3;
+    string directory = 4;
+    optional string commit = 5;
+}

crates/proto/proto/zed.proto πŸ”—

@@ -427,7 +427,11 @@ message Envelope {
         GetTreeDiffResponse get_tree_diff_response = 385;
 
         GetBlobContent get_blob_content = 386;
-        GetBlobContentResponse get_blob_content_response = 387; // current max
+        GetBlobContentResponse get_blob_content_response = 387;
+
+        GitWorktreesResponse git_worktrees_response = 388;
+        GitGetWorktrees git_get_worktrees = 389;
+        GitCreateWorktree git_create_worktree = 390; // current max
     }
 
     reserved 87 to 88;

crates/proto/src/proto.rs πŸ”—

@@ -331,6 +331,9 @@ messages!(
     (ExternalAgentLoadingStatusUpdated, Background),
     (NewExternalAgentVersionAvailable, Background),
     (RemoteStarted, Background),
+    (GitGetWorktrees, Background),
+    (GitWorktreesResponse, Background),
+    (GitCreateWorktree, Background)
 );
 
 request_messages!(
@@ -509,6 +512,8 @@ request_messages!(
     (GetProcesses, GetProcessesResponse),
     (GetAgentServerCommand, AgentServerCommand),
     (RemoteStarted, Ack),
+    (GitGetWorktrees, GitWorktreesResponse),
+    (GitCreateWorktree, Ack)
 );
 
 lsp_messages!(
@@ -672,6 +677,8 @@ entity_messages!(
     ExternalAgentsUpdated,
     ExternalAgentLoadingStatusUpdated,
     NewExternalAgentVersionAvailable,
+    GitGetWorktrees,
+    GitCreateWorktree
 );
 
 entity_messages!(

crates/recent_projects/src/recent_projects.rs πŸ”—

@@ -7,7 +7,7 @@ mod ssh_config;
 mod wsl_picker;
 
 use remote::RemoteConnectionOptions;
-pub use remote_connections::open_remote_project;
+pub use remote_connections::{RemoteConnectionModal, connect, open_remote_project};
 
 use disconnected_overlay::DisconnectedOverlay;
 use fuzzy::{StringMatch, StringMatchCandidate};

crates/recent_projects/src/remote_connections.rs πŸ”—

@@ -137,7 +137,7 @@ impl Drop for RemoteConnectionPrompt {
 }
 
 pub struct RemoteConnectionModal {
-    pub(crate) prompt: Entity<RemoteConnectionPrompt>,
+    pub prompt: Entity<RemoteConnectionPrompt>,
     paths: Vec<PathBuf>,
     finished: bool,
 }
@@ -280,7 +280,7 @@ impl Render for RemoteConnectionPrompt {
 }
 
 impl RemoteConnectionModal {
-    pub(crate) fn new(
+    pub fn new(
         connection_options: &RemoteConnectionOptions,
         paths: Vec<PathBuf>,
         window: &mut Window,

crates/zed_actions/src/lib.rs πŸ”—

@@ -214,7 +214,9 @@ pub mod git {
             #[action(deprecated_aliases = ["branches::OpenRecent"])]
             Branch,
             /// Opens the git stash selector.
-            ViewStash
+            ViewStash,
+            /// Opens the git worktree selector.
+            Worktree
         ]
     );
 }