Git askpass (#25953)

Conrad Irwin and Mikayla Maki created

Supersedes #25848

Release Notes:

- git: Supporting push/pull/fetch when remote requires auth

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>

Change summary

.github/workflows/ci.yml                      |  10 
Cargo.lock                                    |  28 ++
Cargo.toml                                    |   2 
assets/keymaps/default-linux.json             |  10 
assets/keymaps/default-macos.json             |  14 +
crates/askpass/Cargo.toml                     |  21 ++
crates/askpass/LICENSE-APACHE                 |   1 
crates/askpass/src/askpass.rs                 | 194 +++++++++++++++++++++
crates/collab/src/rpc.rs                      |   3 
crates/git/Cargo.toml                         |   3 
crates/git/src/git.rs                         |  14 -
crates/git/src/repository.rs                  | 171 ++++++++---------
crates/git_ui/Cargo.toml                      |   1 
crates/git_ui/src/askpass_modal.rs            | 101 ++++++++++
crates/git_ui/src/commit_modal.rs             |  48 ++--
crates/git_ui/src/git_panel.rs                | 179 ++++++++++++------
crates/git_ui/src/git_ui.rs                   |  28 ++
crates/git_ui/src/project_diff.rs             |   7 
crates/git_ui/src/repository_selector.rs      |  37 ---
crates/project/Cargo.toml                     |  15 
crates/project/src/git.rs                     | 173 ++++++++++++++++-
crates/project/src/project.rs                 |  15 +
crates/proto/proto/zed.proto                  |  23 ++
crates/proto/src/proto.rs                     |   4 
crates/recent_projects/src/ssh_connections.rs |  12 
crates/remote/Cargo.toml                      |   6 
crates/remote/src/ssh_session.rs              | 107 +---------
crates/remote_server/src/headless_project.rs  |  11 
crates/zed/src/main.rs                        |   1 
29 files changed, 862 insertions(+), 377 deletions(-)

Detailed changes

.github/workflows/ci.yml 🔗

@@ -295,7 +295,10 @@ jobs:
       # Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug.
       - name: Clean CI config file
         if: always()
-        run: Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml"  -Force
+        run: |
+          if (Test-Path "${{ env.CARGO_HOME }}/config.toml") {
+            Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml"  -Force
+          }
 
   # Windows CI takes twice as long as our other platforms and fast github hosted runners are expensive.
   # But we still want to do CI, so let's only run tests on main and come back to this when we're
@@ -364,7 +367,10 @@ jobs:
       # Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug.
       - name: Clean CI config file
         if: always()
-        run: Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml"  -Force
+        run: |
+          if (Test-Path "${{ env.CARGO_HOME }}/config.toml") {
+            Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml"  -Force
+          }
 
   bundle-mac:
     timeout-minutes: 120

Cargo.lock 🔗

@@ -257,9 +257,9 @@ checksum = "34cd60c5e3152cef0a592f1b296f1cc93715d89d2551d85315828c3a09575ff4"
 
 [[package]]
 name = "anyhow"
-version = "1.0.97"
+version = "1.0.96"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
+checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4"
 
 [[package]]
 name = "approx"
@@ -358,6 +358,19 @@ dependencies = [
  "zbus",
 ]
 
+[[package]]
+name = "askpass"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "futures 0.3.31",
+ "gpui",
+ "smol",
+ "tempfile",
+ "util",
+ "which 6.0.3",
+]
+
 [[package]]
 name = "assets"
 version = "0.1.0"
@@ -1011,9 +1024,9 @@ dependencies = [
 
 [[package]]
 name = "async-trait"
-version = "0.1.87"
+version = "0.1.86"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97"
+checksum = "644dd749086bf3771a2fbc5f256fdb982d53f011c7d5d560304eafeecebce79d"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -5364,9 +5377,11 @@ name = "git"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "askpass",
  "async-trait",
  "collections",
  "derive_more",
+ "futures 0.3.31",
  "git2",
  "gpui",
  "http_client",
@@ -5380,7 +5395,6 @@ dependencies = [
  "serde_json",
  "smol",
  "sum_tree",
- "tempfile",
  "text",
  "time",
  "unindent",
@@ -5424,6 +5438,7 @@ name = "git_ui"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "askpass",
  "buffer_diff",
  "collections",
  "component",
@@ -10258,6 +10273,7 @@ version = "0.1.0"
 dependencies = [
  "aho-corasick",
  "anyhow",
+ "askpass",
  "async-trait",
  "buffer_diff",
  "client",
@@ -11079,6 +11095,7 @@ name = "remote"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "askpass",
  "async-trait",
  "collections",
  "fs",
@@ -11099,7 +11116,6 @@ dependencies = [
  "tempfile",
  "thiserror 1.0.69",
  "util",
- "which 6.0.3",
 ]
 
 [[package]]

Cargo.toml 🔗

@@ -3,6 +3,7 @@ resolver = "2"
 members = [
     "crates/activity_indicator",
     "crates/anthropic",
+    "crates/askpass",
     "crates/assets",
     "crates/assistant",
     "crates/assistant2",
@@ -207,6 +208,7 @@ edition = "2021"
 activity_indicator = { path = "crates/activity_indicator" }
 ai = { path = "crates/ai" }
 anthropic = { path = "crates/anthropic" }
+askpass = { path = "crates/askpass" }
 assets = { path = "crates/assets" }
 assistant = { path = "crates/assistant" }
 assistant2 = { path = "crates/assistant2" }

assets/keymaps/default-linux.json 🔗

@@ -739,7 +739,7 @@
       "tab": "git_panel::FocusEditor",
       "shift-tab": "git_panel::FocusEditor",
       "escape": "git_panel::ToggleFocus",
-      "ctrl-enter": "git::ShowCommitEditor",
+      "ctrl-enter": "git::Commit",
       "alt-enter": "menu::SecondaryConfirm"
     }
   },
@@ -753,7 +753,13 @@
   {
     "context": "GitDiff > Editor",
     "bindings": {
-      "ctrl-enter": "git::ShowCommitEditor"
+      "ctrl-enter": "git::Commit"
+    }
+  },
+  {
+    "context": "AskPass > Editor",
+    "bindings": {
+      "enter": "menu::Confirm"
     }
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -760,14 +760,21 @@
       "tab": "git_panel::FocusEditor",
       "shift-tab": "git_panel::FocusEditor",
       "escape": "git_panel::ToggleFocus",
-      "cmd-enter": "git::ShowCommitEditor"
+      "cmd-enter": "git::Commit"
     }
   },
   {
     "context": "GitDiff > Editor",
     "use_key_equivalents": true,
     "bindings": {
-      "cmd-enter": "git::ShowCommitEditor"
+      "cmd-enter": "git::Commit"
+    }
+  },
+  {
+    "context": "AskPass > Editor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "enter": "menu::Confirm"
     }
   },
   {
@@ -778,7 +785,8 @@
       "cmd-enter": "git::Commit",
       "tab": "git_panel::FocusChanges",
       "shift-tab": "git_panel::FocusChanges",
-      "alt-up": "git_panel::FocusChanges"
+      "alt-up": "git_panel::FocusChanges",
+      "shift-escape": "git::ExpandCommitEditor"
     }
   },
   {

crates/askpass/Cargo.toml 🔗

@@ -0,0 +1,21 @@
+[package]
+name = "askpass"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/askpass.rs"
+
+[dependencies]
+anyhow.workspace = true
+futures.workspace = true
+gpui.workspace = true
+smol.workspace = true
+tempfile.workspace = true
+util.workspace = true
+which.workspace = true

crates/askpass/src/askpass.rs 🔗

@@ -0,0 +1,194 @@
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+#[cfg(unix)]
+use anyhow::Context as _;
+use futures::channel::{mpsc, oneshot};
+#[cfg(unix)]
+use futures::{io::BufReader, AsyncBufReadExt as _};
+#[cfg(unix)]
+use futures::{select_biased, AsyncWriteExt as _, FutureExt as _};
+use futures::{SinkExt, StreamExt};
+use gpui::{AsyncApp, BackgroundExecutor, Task};
+#[cfg(unix)]
+use smol::fs;
+#[cfg(unix)]
+use smol::{fs::unix::PermissionsExt as _, net::unix::UnixListener};
+#[cfg(unix)]
+use util::ResultExt as _;
+
+#[derive(PartialEq, Eq)]
+pub enum AskPassResult {
+    CancelledByUser,
+    Timedout,
+}
+
+pub struct AskPassDelegate {
+    tx: mpsc::UnboundedSender<(String, oneshot::Sender<String>)>,
+    _task: Task<()>,
+}
+
+impl AskPassDelegate {
+    pub fn new(
+        cx: &mut AsyncApp,
+        password_prompt: impl Fn(String, oneshot::Sender<String>, &mut AsyncApp) + Send + Sync + 'static,
+    ) -> Self {
+        let (tx, mut rx) = mpsc::unbounded::<(String, oneshot::Sender<String>)>();
+        let task = cx.spawn(|mut cx| async move {
+            while let Some((prompt, channel)) = rx.next().await {
+                password_prompt(prompt, channel, &mut cx);
+            }
+        });
+        Self { tx, _task: task }
+    }
+
+    pub async fn ask_password(&mut self, prompt: String) -> anyhow::Result<String> {
+        let (tx, rx) = oneshot::channel();
+        self.tx.send((prompt, tx)).await?;
+        Ok(rx.await?)
+    }
+}
+
+#[cfg(unix)]
+pub struct AskPassSession {
+    script_path: PathBuf,
+    _askpass_task: Task<()>,
+    askpass_opened_rx: Option<oneshot::Receiver<()>>,
+    askpass_kill_master_rx: Option<oneshot::Receiver<()>>,
+}
+
+#[cfg(unix)]
+impl AskPassSession {
+    /// This will create a new AskPassSession.
+    /// You must retain this session until the master process exits.
+    #[must_use]
+    pub async fn new(
+        executor: &BackgroundExecutor,
+        mut delegate: AskPassDelegate,
+    ) -> anyhow::Result<Self> {
+        let temp_dir = tempfile::Builder::new().prefix("zed-askpass").tempdir()?;
+        let askpass_socket = temp_dir.path().join("askpass.sock");
+        let askpass_script_path = temp_dir.path().join("askpass.sh");
+        let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>();
+        let listener =
+            UnixListener::bind(&askpass_socket).context("failed to create askpass socket")?;
+
+        let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<()>();
+        let mut kill_tx = Some(askpass_kill_master_tx);
+
+        let askpass_task = executor.spawn(async move {
+            let mut askpass_opened_tx = Some(askpass_opened_tx);
+
+            while let Ok((mut stream, _)) = listener.accept().await {
+                if let Some(askpass_opened_tx) = askpass_opened_tx.take() {
+                    askpass_opened_tx.send(()).ok();
+                }
+                let mut buffer = Vec::new();
+                let mut reader = BufReader::new(&mut stream);
+                if reader.read_until(b'\0', &mut buffer).await.is_err() {
+                    buffer.clear();
+                }
+                let prompt = String::from_utf8_lossy(&buffer);
+                if let Some(password) = delegate
+                    .ask_password(prompt.to_string())
+                    .await
+                    .context("failed to get askpass password")
+                    .log_err()
+                {
+                    stream.write_all(password.as_bytes()).await.log_err();
+                } else {
+                    if let Some(kill_tx) = kill_tx.take() {
+                        kill_tx.send(()).log_err();
+                    }
+                    // note: we expect the caller to drop this task when it's done.
+                    // We need to keep the stream open until the caller is done to avoid
+                    // spurious errors from ssh.
+                    std::future::pending::<()>().await;
+                    drop(stream);
+                }
+            }
+            drop(temp_dir)
+        });
+
+        anyhow::ensure!(
+            which::which("nc").is_ok(),
+            "Cannot find `nc` command (netcat), which is required to connect over SSH."
+        );
+
+        // Create an askpass script that communicates back to this process.
+        let askpass_script = format!(
+            "{shebang}\n{print_args} | {nc} -U {askpass_socket} 2> /dev/null \n",
+            // on macOS `brew install netcat` provides the GNU netcat implementation
+            // which does not support -U.
+            nc = if cfg!(target_os = "macos") {
+                "/usr/bin/nc"
+            } else {
+                "nc"
+            },
+            askpass_socket = askpass_socket.display(),
+            print_args = "printf '%s\\0' \"$@\"",
+            shebang = "#!/bin/sh",
+        );
+        fs::write(&askpass_script_path, askpass_script).await?;
+        fs::set_permissions(&askpass_script_path, std::fs::Permissions::from_mode(0o755)).await?;
+
+        Ok(Self {
+            script_path: askpass_script_path,
+            _askpass_task: askpass_task,
+            askpass_kill_master_rx: Some(askpass_kill_master_rx),
+            askpass_opened_rx: Some(askpass_opened_rx),
+        })
+    }
+
+    pub fn script_path(&self) -> &Path {
+        &self.script_path
+    }
+
+    // This will run the askpass task forever, resolving as many authentication requests as needed.
+    // The caller is responsible for examining the result of their own commands and cancelling this
+    // future when this is no longer needed. Note that this can only be called once, but due to the
+    // drop order this takes an &mut, so you can `drop()` it after you're done with the master process.
+    pub async fn run(&mut self) -> AskPassResult {
+        let connection_timeout = Duration::from_secs(10);
+        let askpass_opened_rx = self.askpass_opened_rx.take().expect("Only call run once");
+        let askpass_kill_master_rx = self
+            .askpass_kill_master_rx
+            .take()
+            .expect("Only call run once");
+
+        select_biased! {
+            _ = askpass_opened_rx.fuse() => {
+                // Note: this await can only resolve after we are dropped.
+                askpass_kill_master_rx.await.ok();
+                return AskPassResult::CancelledByUser
+            }
+
+            _ = futures::FutureExt::fuse(smol::Timer::after(connection_timeout)) => {
+                return AskPassResult::Timedout
+            }
+        }
+    }
+}
+
+#[cfg(not(unix))]
+pub struct AskPassSession {
+    path: PathBuf,
+}
+
+#[cfg(not(unix))]
+impl AskPassSession {
+    pub async fn new(_: &BackgroundExecutor, _: AskPassDelegate) -> anyhow::Result<Self> {
+        Ok(Self {
+            path: PathBuf::new(),
+        })
+    }
+
+    pub fn script_path(&self) -> &Path {
+        &self.path
+    }
+
+    pub async fn run(&mut self) -> AskPassResult {
+        futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(10))).await;
+        AskPassResult::Timedout
+    }
+}

crates/collab/src/rpc.rs 🔗

@@ -393,9 +393,6 @@ impl Server {
             .add_request_handler(forward_mutating_project_request::<proto::OpenContext>)
             .add_request_handler(forward_mutating_project_request::<proto::CreateContext>)
             .add_request_handler(forward_mutating_project_request::<proto::SynchronizeContexts>)
-            .add_request_handler(forward_mutating_project_request::<proto::Push>)
-            .add_request_handler(forward_mutating_project_request::<proto::Pull>)
-            .add_request_handler(forward_mutating_project_request::<proto::Fetch>)
             .add_request_handler(forward_mutating_project_request::<proto::Stage>)
             .add_request_handler(forward_mutating_project_request::<proto::Unstage>)
             .add_request_handler(forward_mutating_project_request::<proto::Commit>)

crates/git/Cargo.toml 🔗

@@ -16,6 +16,7 @@ test-support = []
 
 [dependencies]
 anyhow.workspace = true
+askpass.workspace = true
 async-trait.workspace = true
 collections.workspace = true
 derive_more.workspace = true
@@ -34,7 +35,7 @@ text.workspace = true
 time.workspace = true
 url.workspace = true
 util.workspace = true
-tempfile.workspace = true
+futures.workspace = true
 
 [dev-dependencies]
 pretty_assertions.workspace = true

crates/git/src/git.rs 🔗

@@ -8,9 +8,6 @@ pub mod status;
 use anyhow::{anyhow, Context as _, Result};
 use gpui::action_with_deprecated_aliases;
 use gpui::actions;
-use gpui::impl_actions;
-use repository::PushOptions;
-use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::ffi::OsStr;
 use std::fmt;
@@ -31,13 +28,6 @@ pub static COMMIT_MESSAGE: LazyLock<&'static OsStr> =
     LazyLock::new(|| OsStr::new("COMMIT_EDITMSG"));
 pub static INDEX_LOCK: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new("index.lock"));
 
-#[derive(Debug, Copy, Clone, PartialEq, Deserialize, JsonSchema)]
-pub struct Push {
-    pub options: Option<PushOptions>,
-}
-
-impl_actions!(git, [Push]);
-
 actions!(
     git,
     [
@@ -54,10 +44,12 @@ actions!(
         RestoreTrackedFiles,
         TrashUntrackedFiles,
         Uncommit,
+        Push,
+        ForcePush,
         Pull,
         Fetch,
         Commit,
-        ShowCommitEditor,
+        ExpandCommitEditor
     ]
 );
 action_with_deprecated_aliases!(git, RestoreFile, ["editor::RevertFile"]);

crates/git/src/repository.rs 🔗

@@ -2,7 +2,9 @@ use crate::status::FileStatus;
 use crate::GitHostingProviderRegistry;
 use crate::{blame::Blame, status::GitStatus};
 use anyhow::{anyhow, Context, Result};
+use askpass::{AskPassResult, AskPassSession};
 use collections::{HashMap, HashSet};
+use futures::{select_biased, FutureExt as _};
 use git2::BranchType;
 use gpui::SharedString;
 use parking_lot::Mutex;
@@ -11,8 +13,6 @@ use schemars::JsonSchema;
 use serde::Deserialize;
 use std::borrow::Borrow;
 use std::io::Write as _;
-#[cfg(not(windows))]
-use std::os::unix::fs::PermissionsExt;
 use std::process::Stdio;
 use std::sync::LazyLock;
 use std::{
@@ -21,9 +21,11 @@ use std::{
     sync::Arc,
 };
 use sum_tree::MapSeekTarget;
-use util::command::new_std_command;
+use util::command::{new_smol_command, new_std_command};
 use util::ResultExt;
 
+pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
+
 #[derive(Clone, Debug, Hash, PartialEq, Eq)]
 pub struct Branch {
     pub is_head: bool,
@@ -200,9 +202,16 @@ pub trait GitRepository: Send + Sync {
         branch_name: &str,
         upstream_name: &str,
         options: Option<PushOptions>,
+        askpass: AskPassSession,
     ) -> Result<RemoteCommandOutput>;
-    fn pull(&self, branch_name: &str, upstream_name: &str) -> Result<RemoteCommandOutput>;
-    fn fetch(&self) -> Result<RemoteCommandOutput>;
+
+    fn pull(
+        &self,
+        branch_name: &str,
+        upstream_name: &str,
+        askpass: AskPassSession,
+    ) -> Result<RemoteCommandOutput>;
+    fn fetch(&self, askpass: AskPassSession) -> Result<RemoteCommandOutput>;
 
     fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>>;
 
@@ -578,7 +587,6 @@ impl GitRepository for RealGitRepository {
                 .args(paths.iter().map(|p| p.as_ref()))
                 .output()?;
 
-            // TODO: Get remote response out of this and show it to the user
             if !output.status.success() {
                 return Err(anyhow!(
                     "Failed to stage paths:\n{}",
@@ -599,7 +607,6 @@ impl GitRepository for RealGitRepository {
                 .args(paths.iter().map(|p| p.as_ref()))
                 .output()?;
 
-            // TODO: Get remote response out of this and show it to the user
             if !output.status.success() {
                 return Err(anyhow!(
                     "Failed to unstage:\n{}",
@@ -625,7 +632,6 @@ impl GitRepository for RealGitRepository {
 
         let output = cmd.output()?;
 
-        // TODO: Get remote response out of this and show it to the user
         if !output.status.success() {
             return Err(anyhow!(
                 "Failed to commit:\n{}",
@@ -640,15 +646,15 @@ impl GitRepository for RealGitRepository {
         branch_name: &str,
         remote_name: &str,
         options: Option<PushOptions>,
+        ask_pass: AskPassSession,
     ) -> Result<RemoteCommandOutput> {
         let working_directory = self.working_directory()?;
 
-        // We do this on every operation to ensure that the askpass script exists and is executable.
-        #[cfg(not(windows))]
-        let (askpass_script_path, _temp_dir) = setup_askpass()?;
-
-        let mut command = new_std_command("git");
+        let mut command = new_smol_command("git");
         command
+            .env("GIT_ASKPASS", ask_pass.script_path())
+            .env("SSH_ASKPASS", ask_pass.script_path())
+            .env("SSH_ASKPASS_REQUIRE", "force")
             .current_dir(&working_directory)
             .args(["push"])
             .args(options.map(|option| match option {
@@ -657,91 +663,46 @@ impl GitRepository for RealGitRepository {
             }))
             .arg(remote_name)
             .arg(format!("{}:{}", branch_name, branch_name));
+        let git_process = command.spawn()?;
 
-        #[cfg(not(windows))]
-        {
-            command.env("GIT_ASKPASS", askpass_script_path);
-        }
-
-        let output = command.output()?;
-
-        if !output.status.success() {
-            return Err(anyhow!(
-                "Failed to push:\n{}",
-                String::from_utf8_lossy(&output.stderr)
-            ));
-        } else {
-            return Ok(RemoteCommandOutput {
-                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
-                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
-            });
-        }
+        run_remote_command(ask_pass, git_process)
     }
 
-    fn pull(&self, branch_name: &str, remote_name: &str) -> Result<RemoteCommandOutput> {
+    fn pull(
+        &self,
+        branch_name: &str,
+        remote_name: &str,
+        ask_pass: AskPassSession,
+    ) -> Result<RemoteCommandOutput> {
         let working_directory = self.working_directory()?;
 
-        // We do this on every operation to ensure that the askpass script exists and is executable.
-        #[cfg(not(windows))]
-        let (askpass_script_path, _temp_dir) = setup_askpass()?;
-
-        let mut command = new_std_command("git");
+        let mut command = new_smol_command("git");
         command
+            .env("GIT_ASKPASS", ask_pass.script_path())
+            .env("SSH_ASKPASS", ask_pass.script_path())
+            .env("SSH_ASKPASS_REQUIRE", "force")
             .current_dir(&working_directory)
             .args(["pull"])
             .arg(remote_name)
             .arg(branch_name);
+        let git_process = command.spawn()?;
 
-        #[cfg(not(windows))]
-        {
-            command.env("GIT_ASKPASS", askpass_script_path);
-        }
-
-        let output = command.output()?;
-
-        if !output.status.success() {
-            return Err(anyhow!(
-                "Failed to pull:\n{}",
-                String::from_utf8_lossy(&output.stderr)
-            ));
-        } else {
-            return Ok(RemoteCommandOutput {
-                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
-                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
-            });
-        }
+        run_remote_command(ask_pass, git_process)
     }
 
-    fn fetch(&self) -> Result<RemoteCommandOutput> {
+    fn fetch(&self, ask_pass: AskPassSession) -> Result<RemoteCommandOutput> {
         let working_directory = self.working_directory()?;
 
-        // We do this on every operation to ensure that the askpass script exists and is executable.
-        #[cfg(not(windows))]
-        let (askpass_script_path, _temp_dir) = setup_askpass()?;
-
-        let mut command = new_std_command("git");
+        let mut command = new_smol_command("git");
         command
+            .env("GIT_ASKPASS", ask_pass.script_path())
+            .env("SSH_ASKPASS", ask_pass.script_path())
+            .env("SSH_ASKPASS_REQUIRE", "force")
             .current_dir(&working_directory)
             .args(["fetch", "--all"]);
+        let git_process = command.spawn()?;
 
-        #[cfg(not(windows))]
-        {
-            command.env("GIT_ASKPASS", askpass_script_path);
-        }
-
-        let output = command.output()?;
-
-        if !output.status.success() {
-            return Err(anyhow!(
-                "Failed to fetch:\n{}",
-                String::from_utf8_lossy(&output.stderr)
-            ));
-        } else {
-            return Ok(RemoteCommandOutput {
-                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
-                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
-            });
-        }
+        run_remote_command(ask_pass, git_process)
     }
 
     fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>> {
@@ -835,16 +796,38 @@ impl GitRepository for RealGitRepository {
     }
 }
 
-#[cfg(not(windows))]
-fn setup_askpass() -> Result<(PathBuf, tempfile::TempDir), anyhow::Error> {
-    let temp_dir = tempfile::Builder::new()
-        .prefix("zed-git-askpass")
-        .tempdir()?;
-    let askpass_script = "#!/bin/sh\necho ''";
-    let askpass_script_path = temp_dir.path().join("git-askpass.sh");
-    std::fs::write(&askpass_script_path, askpass_script)?;
-    std::fs::set_permissions(&askpass_script_path, std::fs::Permissions::from_mode(0o755))?;
-    Ok((askpass_script_path, temp_dir))
+fn run_remote_command(
+    mut ask_pass: AskPassSession,
+    git_process: smol::process::Child,
+) -> std::result::Result<RemoteCommandOutput, anyhow::Error> {
+    smol::block_on(async {
+        select_biased! {
+            result = ask_pass.run().fuse() => {
+                match result {
+                    AskPassResult::CancelledByUser => {
+                        Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
+                    }
+                    AskPassResult::Timedout => {
+                        Err(anyhow!("Connecting to host timed out"))?
+                    }
+                }
+            }
+            output = git_process.output().fuse() => {
+                let output = output?;
+                if !output.status.success() {
+                    Err(anyhow!(
+                        "Operation failed:\n{}",
+                        String::from_utf8_lossy(&output.stderr)
+                    ))
+                } else {
+                    Ok(RemoteCommandOutput {
+                        stdout: String::from_utf8_lossy(&output.stdout).to_string(),
+                        stderr: String::from_utf8_lossy(&output.stderr).to_string(),
+                    })
+                }
+            }
+        }
+    })
 }
 
 #[derive(Debug, Clone)]
@@ -1040,15 +1023,21 @@ impl GitRepository for FakeGitRepository {
         _branch: &str,
         _remote: &str,
         _options: Option<PushOptions>,
+        _ask_pass: AskPassSession,
     ) -> Result<RemoteCommandOutput> {
         unimplemented!()
     }
 
-    fn pull(&self, _branch: &str, _remote: &str) -> Result<RemoteCommandOutput> {
+    fn pull(
+        &self,
+        _branch: &str,
+        _remote: &str,
+        _ask_pass: AskPassSession,
+    ) -> Result<RemoteCommandOutput> {
         unimplemented!()
     }
 
-    fn fetch(&self) -> Result<RemoteCommandOutput> {
+    fn fetch(&self, _ask_pass: AskPassSession) -> Result<RemoteCommandOutput> {
         unimplemented!()
     }
 

crates/git_ui/Cargo.toml 🔗

@@ -18,6 +18,7 @@ test-support = ["multi_buffer/test-support"]
 
 [dependencies]
 anyhow.workspace = true
+askpass.workspace= true
 buffer_diff.workspace = true
 collections.workspace = true
 component.workspace = true

crates/git_ui/src/askpass_modal.rs 🔗

@@ -0,0 +1,101 @@
+use editor::Editor;
+use futures::channel::oneshot;
+use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Styled};
+use ui::{
+    div, h_flex, v_flex, ActiveTheme, App, Context, DynamicSpacing, Headline, HeadlineSize, Icon,
+    IconName, IconSize, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
+    StyledExt, StyledTypography, Window,
+};
+use workspace::ModalView;
+
+pub(crate) struct AskPassModal {
+    operation: SharedString,
+    prompt: SharedString,
+    editor: Entity<Editor>,
+    tx: Option<oneshot::Sender<String>>,
+}
+
+impl EventEmitter<DismissEvent> for AskPassModal {}
+impl ModalView for AskPassModal {}
+impl Focusable for AskPassModal {
+    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
+        self.editor.focus_handle(cx)
+    }
+}
+
+impl AskPassModal {
+    pub fn new(
+        operation: SharedString,
+        prompt: SharedString,
+        tx: oneshot::Sender<String>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let editor = cx.new(|cx| {
+            let mut editor = Editor::single_line(window, cx);
+            if prompt.contains("yes/no") {
+                editor.set_masked(false, cx);
+            } else {
+                editor.set_masked(true, cx);
+            }
+            editor
+        });
+        Self {
+            operation,
+            prompt,
+            editor,
+            tx: Some(tx),
+        }
+    }
+
+    fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context<Self>) {
+        cx.emit(DismissEvent);
+    }
+
+    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(tx) = self.tx.take() {
+            tx.send(self.editor.read(cx).text(cx)).ok();
+        }
+        cx.emit(DismissEvent);
+    }
+}
+
+impl Render for AskPassModal {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex()
+            .key_context("PasswordPrompt")
+            .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(Self::confirm))
+            .elevation_2(cx)
+            .size_full()
+            .font_buffer(cx)
+            .child(
+                h_flex()
+                    .px(DynamicSpacing::Base12.rems(cx))
+                    .pt(DynamicSpacing::Base08.rems(cx))
+                    .pb(DynamicSpacing::Base04.rems(cx))
+                    .rounded_t_md()
+                    .w_full()
+                    .gap_1p5()
+                    .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
+                    .child(h_flex().gap_1().overflow_x_hidden().child(
+                        div().max_w_96().overflow_x_hidden().text_ellipsis().child(
+                            Headline::new(self.operation.clone()).size(HeadlineSize::XSmall),
+                        ),
+                    )),
+            )
+            .child(
+                div()
+                    .text_buffer(cx)
+                    .py_2()
+                    .px_3()
+                    .bg(cx.theme().colors().editor_background)
+                    .border_t_1()
+                    .border_color(cx.theme().colors().border_variant)
+                    .size_full()
+                    .overflow_hidden()
+                    .child(self.prompt.clone())
+                    .child(self.editor.clone()),
+            )
+    }
+}

crates/git_ui/src/commit_modal.rs 🔗

@@ -2,7 +2,7 @@
 
 use crate::branch_picker::{self, BranchList};
 use crate::git_panel::{commit_message_editor, GitPanel};
-use git::{Commit, ShowCommitEditor};
+use git::Commit;
 use panel::{panel_button, panel_editor_style, panel_filled_button};
 use ui::{prelude::*, KeybindingHint, PopoverMenu, Tooltip};
 
@@ -109,30 +109,34 @@ struct RestoreDock {
 
 impl CommitModal {
     pub fn register(workspace: &mut Workspace, _: &mut Window, _cx: &mut Context<Workspace>) {
-        workspace.register_action(|workspace, _: &ShowCommitEditor, window, cx| {
-            let Some(git_panel) = workspace.panel::<GitPanel>(cx) else {
-                return;
-            };
-
-            git_panel.update(cx, |git_panel, cx| {
-                git_panel.set_modal_open(true, cx);
-            });
+        workspace.register_action(|workspace, _: &Commit, window, cx| {
+            CommitModal::toggle(workspace, window, cx);
+        });
+    }
 
-            let dock = workspace.dock_at_position(git_panel.position(window, cx));
-            let is_open = dock.read(cx).is_open();
-            let active_index = dock.read(cx).active_panel_index();
-            let dock = dock.downgrade();
-            let restore_dock_position = RestoreDock {
-                dock,
-                is_open,
-                active_index,
-            };
+    pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<'_, Workspace>) {
+        let Some(git_panel) = workspace.panel::<GitPanel>(cx) else {
+            return;
+        };
 
-            workspace.open_panel::<GitPanel>(window, cx);
-            workspace.toggle_modal(window, cx, move |window, cx| {
-                CommitModal::new(git_panel, restore_dock_position, window, cx)
-            })
+        git_panel.update(cx, |git_panel, cx| {
+            git_panel.set_modal_open(true, cx);
         });
+
+        let dock = workspace.dock_at_position(git_panel.position(window, cx));
+        let is_open = dock.read(cx).is_open();
+        let active_index = dock.read(cx).active_panel_index();
+        let dock = dock.downgrade();
+        let restore_dock_position = RestoreDock {
+            dock,
+            is_open,
+            active_index,
+        };
+
+        workspace.open_panel::<GitPanel>(window, cx);
+        workspace.toggle_modal(window, cx, move |window, cx| {
+            CommitModal::new(git_panel, restore_dock_position, window, cx)
+        })
     }
 
     fn new(

crates/git_ui/src/git_panel.rs 🔗

@@ -1,10 +1,14 @@
-use crate::branch_picker::{self};
+use crate::askpass_modal::AskPassModal;
+use crate::branch_picker;
+use crate::commit_modal::CommitModal;
 use crate::git_panel_settings::StatusStyle;
 use crate::remote_output_toast::{RemoteAction, RemoteOutputToast};
 use crate::{
     git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
 };
 use crate::{picker_prompt, project_diff, ProjectDiff};
+use anyhow::Result;
+use askpass::AskPassDelegate;
 use db::kvp::KEY_VALUE_STORE;
 use editor::commit_tooltip::CommitTooltip;
 
@@ -101,7 +105,7 @@ const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
 
 pub fn init(cx: &mut App) {
     cx.observe_new(
-        |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
+        |workspace: &mut Workspace, _window, _: &mut Context<Workspace>| {
             workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
                 workspace.toggle_panel_focus::<GitPanel>(window, cx);
             });
@@ -1465,13 +1469,19 @@ index 1234567..abcdef0 100644
         cx.notify();
     }
 
-    pub(crate) fn fetch(&mut self, _: &git::Fetch, _window: &mut Window, cx: &mut Context<Self>) {
+    pub(crate) fn fetch(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if !self.can_push_and_pull(cx) {
+            return;
+        }
+
         let Some(repo) = self.active_repository.clone() else {
             return;
         };
         let guard = self.start_remote_operation();
-        let fetch = repo.read(cx).fetch();
+        let askpass = self.askpass_delegate("git fetch", window, cx);
         cx.spawn(|this, mut cx| async move {
+            let fetch = repo.update(&mut cx, |repo, cx| repo.fetch(askpass, cx))?;
+
             let remote_message = fetch.await?;
             drop(guard);
             this.update(&mut cx, |this, cx| {
@@ -1492,7 +1502,10 @@ index 1234567..abcdef0 100644
         .detach_and_log_err(cx);
     }
 
-    pub(crate) fn pull(&mut self, _: &git::Pull, window: &mut Window, cx: &mut Context<Self>) {
+    pub(crate) fn pull(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if !self.can_push_and_pull(cx) {
+            return;
+        }
         let Some(repo) = self.active_repository.clone() else {
             return;
         };
@@ -1501,7 +1514,7 @@ index 1234567..abcdef0 100644
         };
         let branch = branch.clone();
         let remote = self.get_current_remote(window, cx);
-        cx.spawn(move |this, mut cx| async move {
+        cx.spawn_in(window, move |this, mut cx| async move {
             let remote = match remote.await {
                 Ok(Some(remote)) => remote,
                 Ok(None) => {
@@ -1515,12 +1528,16 @@ index 1234567..abcdef0 100644
                 }
             };
 
+            let askpass = this.update_in(&mut cx, |this, window, cx| {
+                this.askpass_delegate(format!("git pull {}", remote.name), window, cx)
+            })?;
+
             let guard = this
                 .update(&mut cx, |this, _| this.start_remote_operation())
                 .ok();
 
-            let pull = repo.update(&mut cx, |repo, _cx| {
-                repo.pull(branch.name.clone(), remote.name.clone())
+            let pull = repo.update(&mut cx, |repo, cx| {
+                repo.pull(branch.name.clone(), remote.name.clone(), askpass, cx)
             })?;
 
             let remote_message = pull.await?;
@@ -1539,7 +1556,10 @@ index 1234567..abcdef0 100644
         .detach_and_log_err(cx);
     }
 
-    pub(crate) fn push(&mut self, action: &git::Push, window: &mut Window, cx: &mut Context<Self>) {
+    pub(crate) fn push(&mut self, force_push: bool, window: &mut Window, cx: &mut Context<Self>) {
+        if !self.can_push_and_pull(cx) {
+            return;
+        }
         let Some(repo) = self.active_repository.clone() else {
             return;
         };
@@ -1547,10 +1567,14 @@ index 1234567..abcdef0 100644
             return;
         };
         let branch = branch.clone();
-        let options = action.options;
+        let options = if force_push {
+            PushOptions::Force
+        } else {
+            PushOptions::SetUpstream
+        };
         let remote = self.get_current_remote(window, cx);
 
-        cx.spawn(move |this, mut cx| async move {
+        cx.spawn_in(window, move |this, mut cx| async move {
             let remote = match remote.await {
                 Ok(Some(remote)) => remote,
                 Ok(None) => {
@@ -1564,16 +1588,25 @@ index 1234567..abcdef0 100644
                 }
             };
 
+            let askpass_delegate = this.update_in(&mut cx, |this, window, cx| {
+                this.askpass_delegate(format!("git push {}", remote.name), window, cx)
+            })?;
+
             let guard = this
                 .update(&mut cx, |this, _| this.start_remote_operation())
                 .ok();
 
-            let push = repo.update(&mut cx, |repo, _cx| {
-                repo.push(branch.name.clone(), remote.name.clone(), options)
+            let push = repo.update(&mut cx, |repo, cx| {
+                repo.push(
+                    branch.name.clone(),
+                    remote.name.clone(),
+                    Some(options),
+                    askpass_delegate,
+                    cx,
+                )
             })?;
 
             let remote_output = push.await?;
-
             drop(guard);
 
             this.update(&mut cx, |this, cx| match remote_output {
@@ -1590,6 +1623,34 @@ index 1234567..abcdef0 100644
         .detach_and_log_err(cx);
     }
 
+    fn askpass_delegate(
+        &self,
+        operation: impl Into<SharedString>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> AskPassDelegate {
+        let this = cx.weak_entity();
+        let operation = operation.into();
+        let window = window.window_handle();
+        AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| {
+            window
+                .update(cx, |_, window, cx| {
+                    this.update(cx, |this, cx| {
+                        this.workspace.update(cx, |workspace, cx| {
+                            workspace.toggle_modal(window, cx, |window, cx| {
+                                AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx)
+                            });
+                        })
+                    })
+                })
+                .ok();
+        })
+    }
+
+    fn can_push_and_pull(&self, cx: &App) -> bool {
+        !self.project.read(cx).is_via_collab()
+    }
+
     fn get_current_remote(
         &mut self,
         window: &mut Window,
@@ -1988,14 +2049,14 @@ index 1234567..abcdef0 100644
         };
         let notif_id = NotificationId::Named("git-operation-error".into());
 
-        let mut message = e.to_string().trim().to_string();
+        let message = e.to_string().trim().to_string();
         let toast;
-        if message.matches("Authentication failed").count() >= 1 {
-            message = format!(
-                "{}\n\n{}",
-                message, "Please set your credentials via the CLI"
-            );
-            toast = Toast::new(notif_id, message);
+        if message
+            .matches(git::repository::REMOTE_CANCELLED_BY_USER)
+            .next()
+            .is_some()
+        {
+            return; // Hide the cancelled by user message
         } else {
             toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
                 window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
@@ -2108,6 +2169,22 @@ index 1234567..abcdef0 100644
         }
     }
 
+    fn expand_commit_editor(
+        &mut self,
+        _: &git::ExpandCommitEditor,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let workspace = self.workspace.clone();
+        window.defer(cx, move |window, cx| {
+            workspace
+                .update(cx, |workspace, cx| {
+                    CommitModal::toggle(workspace, window, cx)
+                })
+                .ok();
+        })
+    }
+
     pub fn render_footer(
         &self,
         window: &mut Window,
@@ -2222,7 +2299,7 @@ index 1234567..abcdef0 100644
                                     .on_click(cx.listener({
                                         move |_, _, window, cx| {
                                             window.dispatch_action(
-                                                git::ShowCommitEditor.boxed_clone(),
+                                                git::ExpandCommitEditor.boxed_clone(),
                                                 cx,
                                             )
                                         }
@@ -2840,6 +2917,7 @@ impl Render for GitPanel {
             .on_action(cx.listener(Self::unstage_all))
             .on_action(cx.listener(Self::restore_tracked_files))
             .on_action(cx.listener(Self::clean_all))
+            .on_action(cx.listener(Self::expand_commit_editor))
             .when(has_write_access && has_co_authors, |git_panel| {
                 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
             })
@@ -2949,7 +3027,7 @@ impl Panel for GitPanel {
     }
 
     fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
-        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
+        Some(ui::IconName::GitBranchSmall).filter(|_| GitPanelSettings::get_global(cx).button)
     }
 
     fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
@@ -3168,14 +3246,8 @@ fn render_git_action_menu(id: impl Into<ElementId>) -> impl IntoElement {
                     .action("Fetch", git::Fetch.boxed_clone())
                     .action("Pull", git::Pull.boxed_clone())
                     .separator()
-                    .action("Push", git::Push { options: None }.boxed_clone())
-                    .action(
-                        "Force Push",
-                        git::Push {
-                            options: Some(PushOptions::Force),
-                        }
-                        .boxed_clone(),
-                    )
+                    .action("Push", git::Push.boxed_clone())
+                    .action("Force Push", git::ForcePush.boxed_clone())
             }))
         })
         .anchor(Corner::TopRight)
@@ -3253,14 +3325,14 @@ impl PanelRepoFooter {
             move |_, window, cx| {
                 if let Some(panel) = panel.as_ref() {
                     panel.update(cx, |panel, cx| {
-                        panel.push(&git::Push { options: None }, window, cx);
+                        panel.push(false, window, cx);
                     });
                 }
             },
             move |window, cx| {
                 git_action_tooltip(
                     "Push committed changes to remote",
-                    &git::Push { options: None },
+                    &git::Push,
                     "git push",
                     panel_focus_handle.clone(),
                     window,
@@ -3289,7 +3361,7 @@ impl PanelRepoFooter {
             move |_, window, cx| {
                 if let Some(panel) = panel.as_ref() {
                     panel.update(cx, |panel, cx| {
-                        panel.pull(&git::Pull, window, cx);
+                        panel.pull(window, cx);
                     });
                 }
             },
@@ -3319,7 +3391,7 @@ impl PanelRepoFooter {
             move |_, window, cx| {
                 if let Some(panel) = panel.as_ref() {
                     panel.update(cx, |panel, cx| {
-                        panel.fetch(&git::Fetch, window, cx);
+                        panel.fetch(window, cx);
                     });
                 }
             },
@@ -3349,22 +3421,14 @@ impl PanelRepoFooter {
             move |_, window, cx| {
                 if let Some(panel) = panel.as_ref() {
                     panel.update(cx, |panel, cx| {
-                        panel.push(
-                            &git::Push {
-                                options: Some(PushOptions::SetUpstream),
-                            },
-                            window,
-                            cx,
-                        );
+                        panel.push(false, window, cx);
                     });
                 }
             },
             move |window, cx| {
                 git_action_tooltip(
                     "Publish branch to remote",
-                    &git::Push {
-                        options: Some(PushOptions::SetUpstream),
-                    },
+                    &git::Push,
                     "git push --set-upstream",
                     panel_focus_handle.clone(),
                     window,
@@ -3387,22 +3451,14 @@ impl PanelRepoFooter {
             move |_, window, cx| {
                 if let Some(panel) = panel.as_ref() {
                     panel.update(cx, |panel, cx| {
-                        panel.push(
-                            &git::Push {
-                                options: Some(PushOptions::SetUpstream),
-                            },
-                            window,
-                            cx,
-                        );
+                        panel.push(false, window, cx);
                     });
                 }
             },
             move |window, cx| {
                 git_action_tooltip(
                     "Re-publish branch to remote",
-                    &git::Push {
-                        options: Some(PushOptions::SetUpstream),
-                    },
+                    &git::Push,
                     "git push --set-upstream",
                     panel_focus_handle.clone(),
                     window,
@@ -3417,10 +3473,15 @@ impl PanelRepoFooter {
         id: impl Into<SharedString>,
         branch: &Branch,
         cx: &mut App,
-    ) -> impl IntoElement {
+    ) -> Option<impl IntoElement> {
+        if let Some(git_panel) = self.git_panel.as_ref() {
+            if !git_panel.read(cx).can_push_and_pull(cx) {
+                return None;
+            }
+        }
         let id = id.into();
         let upstream = branch.upstream.as_ref();
-        match upstream {
+        Some(match upstream {
             Some(Upstream {
                 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
                 ..
@@ -3434,7 +3495,7 @@ impl PanelRepoFooter {
                 ..
             }) => self.render_republish_button(id, cx),
             None => self.render_publish_button(id, cx),
-        }
+        })
     }
 }
 
@@ -3550,7 +3611,7 @@ impl RenderOnce for PanelRepoFooter {
                     .child(self.render_overflow_menu(overflow_menu_id))
                     .when_some(branch, |this, branch| {
                         let button = self.render_relevant_button(self.id.clone(), &branch, cx);
-                        this.child(button)
+                        this.children(button)
                     }),
             )
     }

crates/git_ui/src/git_ui.rs 🔗

@@ -6,6 +6,7 @@ use project_diff::ProjectDiff;
 use ui::{ActiveTheme, Color, Icon, IconName, IntoElement};
 use workspace::Workspace;
 
+mod askpass_modal;
 pub mod branch_picker;
 mod commit_modal;
 pub mod git_panel;
@@ -20,30 +21,43 @@ pub fn init(cx: &mut App) {
     branch_picker::init(cx);
     cx.observe_new(ProjectDiff::register).detach();
     commit_modal::init(cx);
+    git_panel::init(cx);
 
-    cx.observe_new(|workspace: &mut Workspace, _, _| {
-        workspace.register_action(|workspace, fetch: &git::Fetch, window, cx| {
+    cx.observe_new(|workspace: &mut Workspace, _, cx| {
+        let project = workspace.project().read(cx);
+        if project.is_via_collab() {
+            return;
+        }
+        workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
             let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
                 return;
             };
             panel.update(cx, |panel, cx| {
-                panel.fetch(fetch, window, cx);
+                panel.fetch(window, cx);
             });
         });
-        workspace.register_action(|workspace, push: &git::Push, window, cx| {
+        workspace.register_action(|workspace, _: &git::Push, window, cx| {
             let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
                 return;
             };
             panel.update(cx, |panel, cx| {
-                panel.push(push, window, cx);
+                panel.push(false, window, cx);
             });
         });
-        workspace.register_action(|workspace, pull: &git::Pull, window, cx| {
+        workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
             let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
                 return;
             };
             panel.update(cx, |panel, cx| {
-                panel.pull(pull, window, cx);
+                panel.push(true, window, cx);
+            });
+        });
+        workspace.register_action(|workspace, _: &git::Pull, window, cx| {
+            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+                return;
+            };
+            panel.update(cx, |panel, cx| {
+                panel.pull(window, cx);
             });
         });
     })

crates/git_ui/src/project_diff.rs 🔗

@@ -10,8 +10,7 @@ use editor::{
 use feature_flags::FeatureFlagViewExt;
 use futures::StreamExt;
 use git::{
-    status::FileStatus, ShowCommitEditor, StageAll, StageAndNext, ToggleStaged, UnstageAll,
-    UnstageAndNext,
+    status::FileStatus, Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
 };
 use gpui::{
     actions, Action, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity,
@@ -923,11 +922,11 @@ impl Render for ProjectDiffToolbar {
                         Button::new("commit", "Commit")
                             .tooltip(Tooltip::for_action_title_in(
                                 "Commit",
-                                &ShowCommitEditor,
+                                &Commit,
                                 &focus_handle,
                             ))
                             .on_click(cx.listener(|this, _, window, cx| {
-                                this.dispatch_action(&ShowCommitEditor, window, cx);
+                                this.dispatch_action(&Commit, window, cx);
                             })),
                     ),
             )

crates/git_ui/src/repository_selector.rs 🔗

@@ -1,22 +1,14 @@
 use gpui::{
-    AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription,
-    Task, WeakEntity,
+    AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity,
 };
 use itertools::Itertools;
 use picker::{Picker, PickerDelegate};
-use project::{
-    git::{GitStore, Repository},
-    Project,
-};
+use project::{git::Repository, Project};
 use std::sync::Arc;
 use ui::{prelude::*, ListItem, ListItemSpacing};
 
 pub struct RepositorySelector {
     picker: Entity<Picker<RepositorySelectorDelegate>>,
-    /// The task used to update the picker's matches when there is a change to
-    /// the repository list.
-    update_matches_task: Option<Task<()>>,
-    _subscriptions: Vec<Subscription>,
 }
 
 impl RepositorySelector {
@@ -51,30 +43,7 @@ impl RepositorySelector {
                 .max_height(Some(rems(20.).into()))
         });
 
-        let _subscriptions =
-            vec![cx.subscribe_in(&git_store, window, Self::handle_project_git_event)];
-
-        RepositorySelector {
-            picker,
-            update_matches_task: None,
-            _subscriptions,
-        }
-    }
-
-    fn handle_project_git_event(
-        &mut self,
-        git_store: &Entity<GitStore>,
-        _event: &project::git::GitEvent,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        // TODO handle events individually
-        let task = self.picker.update(cx, |this, cx| {
-            let query = this.query(cx);
-            this.delegate.repository_entries = git_store.read(cx).all_repositories();
-            this.delegate.update_matches(query, window, cx)
-        });
-        self.update_matches_task = Some(task);
+        RepositorySelector { picker }
     }
 }
 

crates/project/Cargo.toml 🔗

@@ -27,11 +27,13 @@ test-support = [
 [dependencies]
 aho-corasick.workspace = true
 anyhow.workspace = true
+askpass.workspace = true
 async-trait.workspace = true
+buffer_diff.workspace = true
 client.workspace = true
 clock.workspace = true
 collections.workspace = true
-buffer_diff.workspace = true
+fancy-regex.workspace = true
 fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
@@ -39,25 +41,22 @@ git.workspace = true
 globset.workspace = true
 gpui.workspace = true
 http_client.workspace = true
+image.workspace = true
 itertools.workspace = true
 language.workspace = true
 log.workspace = true
 lsp.workspace = true
 node_runtime.workspace = true
-image.workspace = true
 parking_lot.workspace = true
 pathdiff.workspace = true
 paths.workspace = true
 postage.workspace = true
 prettier.workspace = true
-worktree.workspace = true
 rand.workspace = true
 regex.workspace = true
 remote.workspace = true
 rpc.workspace = true
 schemars.workspace = true
-task.workspace = true
-tempfile.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
@@ -67,13 +66,15 @@ shlex.workspace = true
 smol.workspace = true
 snippet.workspace = true
 snippet_provider.workspace = true
+task.workspace = true
+tempfile.workspace = true
 terminal.workspace = true
 text.workspace = true
 toml.workspace = true
-util.workspace = true
 url.workspace = true
+util.workspace = true
 which.workspace = true
-fancy-regex.workspace = true
+worktree.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }

crates/project/src/git.rs 🔗

@@ -4,8 +4,10 @@ use crate::{
     Project, ProjectItem, ProjectPath,
 };
 use anyhow::{Context as _, Result};
+use askpass::{AskPassDelegate, AskPassSession};
 use buffer_diff::BufferDiffEvent;
 use client::ProjectId;
+use collections::HashMap;
 use futures::{
     channel::{mpsc, oneshot},
     StreamExt as _,
@@ -22,6 +24,7 @@ use gpui::{
     WeakEntity,
 };
 use language::{Buffer, LanguageRegistry};
+use parking_lot::Mutex;
 use rpc::{
     proto::{self, git_reset, ToProto},
     AnyProtoClient, TypedEnvelope,
@@ -34,13 +37,13 @@ use std::{
     sync::Arc,
 };
 use text::BufferId;
-use util::{maybe, ResultExt};
+use util::{debug_panic, maybe, ResultExt};
 use worktree::{ProjectEntryId, RepositoryEntry, StatusEntry, WorkDirectory};
 
 pub struct GitStore {
     buffer_store: Entity<BufferStore>,
     pub(super) project_id: Option<ProjectId>,
-    pub(super) client: Option<AnyProtoClient>,
+    pub(super) client: AnyProtoClient,
     repositories: Vec<Entity<Repository>>,
     active_index: Option<usize>,
     update_sender: mpsc::UnboundedSender<GitJob>,
@@ -55,6 +58,8 @@ pub struct Repository {
     pub git_repo: GitRepo,
     pub merge_message: Option<String>,
     job_sender: mpsc::UnboundedSender<GitJob>,
+    askpass_delegates: Arc<Mutex<HashMap<u64, AskPassDelegate>>>,
+    latest_askpass_id: u64,
 }
 
 #[derive(Clone)]
@@ -92,7 +97,7 @@ impl GitStore {
     pub fn new(
         worktree_store: &Entity<WorktreeStore>,
         buffer_store: Entity<BufferStore>,
-        client: Option<AnyProtoClient>,
+        client: AnyProtoClient,
         project_id: Option<ProjectId>,
         cx: &mut Context<'_, Self>,
     ) -> Self {
@@ -129,6 +134,7 @@ impl GitStore {
         client.add_entity_request_handler(Self::handle_checkout_files);
         client.add_entity_request_handler(Self::handle_open_commit_message_buffer);
         client.add_entity_request_handler(Self::handle_set_index_text);
+        client.add_entity_request_handler(Self::handle_askpass);
         client.add_entity_request_handler(Self::handle_check_for_pushed_commits);
     }
 
@@ -164,7 +170,7 @@ impl GitStore {
                                 )
                             })
                             .or_else(|| {
-                                let client = client.clone()?;
+                                let client = client.clone();
                                 let project_id = project_id?;
                                 Some((
                                     GitRepo::Remote {
@@ -216,6 +222,8 @@ impl GitStore {
                             cx.new(|_| Repository {
                                 git_store: this.clone(),
                                 worktree_id,
+                                askpass_delegates: Default::default(),
+                                latest_askpass_id: 0,
                                 repository_entry: repo.clone(),
                                 git_repo,
                                 job_sender: self.update_sender.clone(),
@@ -362,9 +370,21 @@ impl GitStore {
         let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
         let repository_handle =
             Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
+        let askpass_id = envelope.payload.askpass_id;
+
+        let askpass = make_remote_delegate(
+            this,
+            envelope.payload.project_id,
+            worktree_id,
+            work_directory_id,
+            askpass_id,
+            &mut cx,
+        );
 
         let remote_output = repository_handle
-            .update(&mut cx, |repository_handle, _cx| repository_handle.fetch())?
+            .update(&mut cx, |repository_handle, cx| {
+                repository_handle.fetch(askpass, cx)
+            })?
             .await??;
 
         Ok(proto::RemoteMessageResponse {
@@ -383,6 +403,16 @@ impl GitStore {
         let repository_handle =
             Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
 
+        let askpass_id = envelope.payload.askpass_id;
+        let askpass = make_remote_delegate(
+            this,
+            envelope.payload.project_id,
+            worktree_id,
+            work_directory_id,
+            askpass_id,
+            &mut cx,
+        );
+
         let options = envelope
             .payload
             .options
@@ -396,8 +426,8 @@ impl GitStore {
         let remote_name = envelope.payload.remote_name.into();
 
         let remote_output = repository_handle
-            .update(&mut cx, |repository_handle, _cx| {
-                repository_handle.push(branch_name, remote_name, options)
+            .update(&mut cx, |repository_handle, cx| {
+                repository_handle.push(branch_name, remote_name, options, askpass, cx)
             })?
             .await??;
         Ok(proto::RemoteMessageResponse {
@@ -415,15 +445,25 @@ impl GitStore {
         let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
         let repository_handle =
             Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
+        let askpass_id = envelope.payload.askpass_id;
+        let askpass = make_remote_delegate(
+            this,
+            envelope.payload.project_id,
+            worktree_id,
+            work_directory_id,
+            askpass_id,
+            &mut cx,
+        );
 
         let branch_name = envelope.payload.branch_name.into();
         let remote_name = envelope.payload.remote_name.into();
 
         let remote_message = repository_handle
-            .update(&mut cx, |repository_handle, _cx| {
-                repository_handle.pull(branch_name, remote_name)
+            .update(&mut cx, |repository_handle, cx| {
+                repository_handle.pull(branch_name, remote_name, askpass, cx)
             })?
             .await??;
+
         Ok(proto::RemoteMessageResponse {
             stdout: remote_message.stdout,
             stderr: remote_message.stderr,
@@ -719,6 +759,31 @@ impl GitStore {
         })
     }
 
+    async fn handle_askpass(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::AskPassRequest>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::AskPassResponse> {
+        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
+        let repository =
+            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
+
+        let delegates = cx.update(|cx| repository.read(cx).askpass_delegates.clone())?;
+        let Some(mut askpass) = delegates.lock().remove(&envelope.payload.askpass_id) else {
+            debug_panic!("no askpass found");
+            return Err(anyhow::anyhow!("no askpass found"));
+        };
+
+        let response = askpass.ask_password(envelope.payload.prompt).await?;
+
+        delegates
+            .lock()
+            .insert(envelope.payload.askpass_id, askpass);
+
+        Ok(proto::AskPassResponse { response })
+    }
+
     async fn handle_check_for_pushed_commits(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::CheckForPushedCommits>,
@@ -765,6 +830,33 @@ impl GitStore {
     }
 }
 
+fn make_remote_delegate(
+    this: Entity<GitStore>,
+    project_id: u64,
+    worktree_id: WorktreeId,
+    work_directory_id: ProjectEntryId,
+    askpass_id: u64,
+    cx: &mut AsyncApp,
+) -> AskPassDelegate {
+    AskPassDelegate::new(cx, move |prompt, tx, cx| {
+        this.update(cx, |this, cx| {
+            let response = this.client.request(proto::AskPassRequest {
+                project_id,
+                worktree_id: worktree_id.to_proto(),
+                work_directory_id: work_directory_id.to_proto(),
+                askpass_id,
+                prompt,
+            });
+            cx.spawn(|_, _| async move {
+                tx.send(response.await?.response).ok();
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+        })
+        .log_err();
+    })
+}
+
 impl GitRepo {}
 
 impl Repository {
@@ -1286,21 +1378,39 @@ impl Repository {
         })
     }
 
-    pub fn fetch(&self) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
-        self.send_job(|git_repo| async move {
+    pub fn fetch(
+        &mut self,
+        askpass: AskPassDelegate,
+        cx: &App,
+    ) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
+        let executor = cx.background_executor().clone();
+        let askpass_delegates = self.askpass_delegates.clone();
+        let askpass_id = util::post_inc(&mut self.latest_askpass_id);
+
+        self.send_job(move |git_repo| async move {
             match git_repo {
-                GitRepo::Local(git_repository) => git_repository.fetch(),
+                GitRepo::Local(git_repository) => {
+                    let askpass = AskPassSession::new(&executor, askpass).await?;
+                    git_repository.fetch(askpass)
+                }
                 GitRepo::Remote {
                     project_id,
                     client,
                     worktree_id,
                     work_directory_id,
                 } => {
+                    askpass_delegates.lock().insert(askpass_id, askpass);
+                    let _defer = util::defer(|| {
+                        let askpass_delegate = askpass_delegates.lock().remove(&askpass_id);
+                        debug_assert!(askpass_delegate.is_some());
+                    });
+
                     let response = client
                         .request(proto::Fetch {
                             project_id: project_id.0,
                             worktree_id: worktree_id.to_proto(),
                             work_directory_id: work_directory_id.to_proto(),
+                            askpass_id,
                         })
                         .await
                         .context("sending fetch request")?;
@@ -1315,25 +1425,40 @@ impl Repository {
     }
 
     pub fn push(
-        &self,
+        &mut self,
         branch: SharedString,
         remote: SharedString,
         options: Option<PushOptions>,
+        askpass: AskPassDelegate,
+        cx: &App,
     ) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
+        let executor = cx.background_executor().clone();
+        let askpass_delegates = self.askpass_delegates.clone();
+        let askpass_id = util::post_inc(&mut self.latest_askpass_id);
+
         self.send_job(move |git_repo| async move {
             match git_repo {
-                GitRepo::Local(git_repository) => git_repository.push(&branch, &remote, options),
+                GitRepo::Local(git_repository) => {
+                    let askpass = AskPassSession::new(&executor, askpass).await?;
+                    git_repository.push(&branch, &remote, options, askpass)
+                }
                 GitRepo::Remote {
                     project_id,
                     client,
                     worktree_id,
                     work_directory_id,
                 } => {
+                    askpass_delegates.lock().insert(askpass_id, askpass);
+                    let _defer = util::defer(|| {
+                        let askpass_delegate = askpass_delegates.lock().remove(&askpass_id);
+                        debug_assert!(askpass_delegate.is_some());
+                    });
                     let response = client
                         .request(proto::Push {
                             project_id: project_id.0,
                             worktree_id: worktree_id.to_proto(),
                             work_directory_id: work_directory_id.to_proto(),
+                            askpass_id,
                             branch_name: branch.to_string(),
                             remote_name: remote.to_string(),
                             options: options.map(|options| match options {
@@ -1354,24 +1479,38 @@ impl Repository {
     }
 
     pub fn pull(
-        &self,
+        &mut self,
         branch: SharedString,
         remote: SharedString,
+        askpass: AskPassDelegate,
+        cx: &App,
     ) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
-        self.send_job(|git_repo| async move {
+        let executor = cx.background_executor().clone();
+        let askpass_delegates = self.askpass_delegates.clone();
+        let askpass_id = util::post_inc(&mut self.latest_askpass_id);
+        self.send_job(move |git_repo| async move {
             match git_repo {
-                GitRepo::Local(git_repository) => git_repository.pull(&branch, &remote),
+                GitRepo::Local(git_repository) => {
+                    let askpass = AskPassSession::new(&executor, askpass).await?;
+                    git_repository.pull(&branch, &remote, askpass)
+                }
                 GitRepo::Remote {
                     project_id,
                     client,
                     worktree_id,
                     work_directory_id,
                 } => {
+                    askpass_delegates.lock().insert(askpass_id, askpass);
+                    let _defer = util::defer(|| {
+                        let askpass_delegate = askpass_delegates.lock().remove(&askpass_id);
+                        debug_assert!(askpass_delegate.is_some());
+                    });
                     let response = client
                         .request(proto::Pull {
                             project_id: project_id.0,
                             worktree_id: worktree_id.to_proto(),
                             work_directory_id: work_directory_id.to_proto(),
+                            askpass_id,
                             branch_name: branch.to_string(),
                             remote_name: remote.to_string(),
                         })

crates/project/src/project.rs 🔗

@@ -707,8 +707,15 @@ impl Project {
                 )
             });
 
-            let git_store =
-                cx.new(|cx| GitStore::new(&worktree_store, buffer_store.clone(), None, None, cx));
+            let git_store = cx.new(|cx| {
+                GitStore::new(
+                    &worktree_store,
+                    buffer_store.clone(),
+                    client.clone().into(),
+                    None,
+                    cx,
+                )
+            });
 
             cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
 
@@ -832,7 +839,7 @@ impl Project {
                 GitStore::new(
                     &worktree_store,
                     buffer_store.clone(),
-                    Some(ssh_proto.clone()),
+                    ssh_proto.clone(),
                     Some(ProjectId(SSH_PROJECT_ID)),
                     cx,
                 )
@@ -1040,7 +1047,7 @@ impl Project {
             GitStore::new(
                 &worktree_store,
                 buffer_store.clone(),
-                Some(client.clone().into()),
+                client.clone().into(),
                 Some(ProjectId(remote_id)),
                 cx,
             )

crates/proto/proto/zed.proto 🔗

@@ -333,12 +333,16 @@ message Envelope {
         ApplyCodeActionKindResponse apply_code_action_kind_response = 310;
 
         RemoteMessageResponse remote_message_response = 311;
+
         GitGetBranches git_get_branches = 312;
         GitCreateBranch git_create_branch = 313;
-        GitChangeBranch git_change_branch = 314; // current max
+        GitChangeBranch git_change_branch = 314;
 
         CheckForPushedCommits check_for_pushed_commits = 315;
-        CheckForPushedCommitsResponse check_for_pushed_commits_response = 316; // current max
+        CheckForPushedCommitsResponse check_for_pushed_commits_response = 316;
+
+        AskPassRequest ask_pass_request = 317;
+        AskPassResponse ask_pass_response = 318; // current max
     }
 
     reserved 87 to 88;
@@ -2818,6 +2822,7 @@ message Push {
     string remote_name = 4;
     string branch_name = 5;
     optional PushOptions options = 6;
+    uint64 askpass_id = 7;
 
     enum PushOptions {
         SET_UPSTREAM = 0;
@@ -2829,6 +2834,7 @@ message Fetch {
     uint64 project_id = 1;
     uint64 worktree_id = 2;
     uint64 work_directory_id = 3;
+    uint64 askpass_id = 4;
 }
 
 message GetRemotes {
@@ -2852,6 +2858,7 @@ message Pull {
     uint64 work_directory_id = 3;
     string remote_name = 4;
     string branch_name = 5;
+    uint64 askpass_id = 6;
 }
 
 message RemoteMessageResponse {
@@ -2859,6 +2866,18 @@ message RemoteMessageResponse {
     string stderr = 2;
 }
 
+message AskPassRequest {
+    uint64 project_id = 1;
+    uint64 worktree_id = 2;
+    uint64 work_directory_id = 3;
+    uint64 askpass_id = 4;
+    string prompt = 5;
+}
+
+message AskPassResponse {
+    string response = 1;
+}
+
 message GitGetBranches {
     uint64 project_id = 1;
     uint64 worktree_id = 2;

crates/proto/src/proto.rs 🔗

@@ -452,6 +452,8 @@ messages!(
     (GetRemotesResponse, Background),
     (Pull, Background),
     (RemoteMessageResponse, Background),
+    (AskPassRequest, Background),
+    (AskPassResponse, Background),
     (GitCreateBranch, Background),
     (GitChangeBranch, Background),
     (CheckForPushedCommits, Background),
@@ -598,6 +600,7 @@ request_messages!(
     (Fetch, RemoteMessageResponse),
     (GetRemotes, GetRemotesResponse),
     (Pull, RemoteMessageResponse),
+    (AskPassRequest, AskPassResponse),
     (GitCreateBranch, Ack),
     (GitChangeBranch, Ack),
     (CheckForPushedCommits, CheckForPushedCommitsResponse),
@@ -702,6 +705,7 @@ entity_messages!(
     Fetch,
     GetRemotes,
     Pull,
+    AskPassRequest,
     GitChangeBranch,
     GitCreateBranch,
     CheckForPushedCommits,

crates/recent_projects/src/ssh_connections.rs 🔗

@@ -131,7 +131,7 @@ pub struct SshPrompt {
     connection_string: SharedString,
     nickname: Option<SharedString>,
     status_message: Option<SharedString>,
-    prompt: Option<(Entity<Markdown>, oneshot::Sender<Result<String>>)>,
+    prompt: Option<(Entity<Markdown>, oneshot::Sender<String>)>,
     cancellation: Option<oneshot::Sender<()>>,
     editor: Entity<Editor>,
 }
@@ -176,7 +176,7 @@ impl SshPrompt {
     pub fn set_prompt(
         &mut self,
         prompt: String,
-        tx: oneshot::Sender<Result<String>>,
+        tx: oneshot::Sender<String>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -223,7 +223,7 @@ impl SshPrompt {
         if let Some((_, tx)) = self.prompt.take() {
             self.status_message = Some("Connecting".into());
             self.editor.update(cx, |editor, cx| {
-                tx.send(Ok(editor.text(cx))).ok();
+                tx.send(editor.text(cx)).ok();
                 editor.clear(window, cx);
             });
         }
@@ -429,11 +429,10 @@ pub struct SshClientDelegate {
 }
 
 impl remote::SshClientDelegate for SshClientDelegate {
-    fn ask_password(&self, prompt: String, cx: &mut AsyncApp) -> oneshot::Receiver<Result<String>> {
-        let (tx, rx) = oneshot::channel();
+    fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp) {
         let mut known_password = self.known_password.clone();
         if let Some(password) = known_password.take() {
-            tx.send(Ok(password)).ok();
+            tx.send(password).ok();
         } else {
             self.window
                 .update(cx, |_, window, cx| {
@@ -443,7 +442,6 @@ impl remote::SshClientDelegate for SshClientDelegate {
                 })
                 .ok();
         }
-        rx
     }
 
     fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {

crates/remote/Cargo.toml 🔗

@@ -19,6 +19,7 @@ test-support = ["fs/test-support"]
 
 [dependencies]
 anyhow.workspace = true
+askpass.workspace = true
 async-trait.workspace = true
 collections.workspace = true
 fs.workspace = true
@@ -26,9 +27,10 @@ futures.workspace = true
 gpui.workspace = true
 itertools.workspace = true
 log.workspace = true
-paths.workspace = true
 parking_lot.workspace = true
+paths.workspace = true
 prost.workspace = true
+release_channel.workspace = true
 rpc = { workspace = true, features = ["gpui"] }
 schemars.workspace =  true
 serde.workspace = true
@@ -38,8 +40,6 @@ smol.workspace = true
 tempfile.workspace = true
 thiserror.workspace = true
 util.workspace = true
-release_channel.workspace = true
-which.workspace = true
 
 [dev-dependencies]
 gpui = { workspace = true, features = ["test-support"] }

crates/remote/src/ssh_session.rs 🔗

@@ -316,7 +316,7 @@ impl SshPlatform {
 }
 
 pub trait SshClientDelegate: Send + Sync {
-    fn ask_password(&self, prompt: String, cx: &mut AsyncApp) -> oneshot::Receiver<Result<String>>;
+    fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp);
     fn get_download_params(
         &self,
         platform: SshPlatform,
@@ -1454,83 +1454,22 @@ impl SshRemoteConnection {
         delegate: Arc<dyn SshClientDelegate>,
         cx: &mut AsyncApp,
     ) -> Result<Self> {
-        use futures::AsyncWriteExt as _;
-        use futures::{io::BufReader, AsyncBufReadExt as _};
-        use smol::net::unix::UnixStream;
-        use smol::{fs::unix::PermissionsExt as _, net::unix::UnixListener};
-        use util::ResultExt as _;
+        use askpass::AskPassResult;
 
         delegate.set_status(Some("Connecting"), cx);
 
         let url = connection_options.ssh_url();
+
         let temp_dir = tempfile::Builder::new()
             .prefix("zed-ssh-session")
             .tempdir()?;
-
-        // Create a domain socket listener to handle requests from the askpass program.
-        let askpass_socket = temp_dir.path().join("askpass.sock");
-        let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>();
-        let listener =
-            UnixListener::bind(&askpass_socket).context("failed to create askpass socket")?;
-
-        let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<UnixStream>();
-        let mut kill_tx = Some(askpass_kill_master_tx);
-
-        let askpass_task = cx.spawn({
+        let askpass_delegate = askpass::AskPassDelegate::new(cx, {
             let delegate = delegate.clone();
-            |mut cx| async move {
-                let mut askpass_opened_tx = Some(askpass_opened_tx);
-
-                while let Ok((mut stream, _)) = listener.accept().await {
-                    if let Some(askpass_opened_tx) = askpass_opened_tx.take() {
-                        askpass_opened_tx.send(()).ok();
-                    }
-                    let mut buffer = Vec::new();
-                    let mut reader = BufReader::new(&mut stream);
-                    if reader.read_until(b'\0', &mut buffer).await.is_err() {
-                        buffer.clear();
-                    }
-                    let password_prompt = String::from_utf8_lossy(&buffer);
-                    if let Some(password) = delegate
-                        .ask_password(password_prompt.to_string(), &mut cx)
-                        .await
-                        .context("failed to get ssh password")
-                        .and_then(|p| p)
-                        .log_err()
-                    {
-                        stream.write_all(password.as_bytes()).await.log_err();
-                    } else {
-                        if let Some(kill_tx) = kill_tx.take() {
-                            kill_tx.send(stream).log_err();
-                            break;
-                        }
-                    }
-                }
-            }
+            move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx)
         });
 
-        anyhow::ensure!(
-            which::which("nc").is_ok(),
-            "Cannot find `nc` command (netcat), which is required to connect over SSH."
-        );
-
-        // Create an askpass script that communicates back to this process.
-        let askpass_script = format!(
-            "{shebang}\n{print_args} | {nc} -U {askpass_socket} 2> /dev/null \n",
-            // on macOS `brew install netcat` provides the GNU netcat implementation
-            // which does not support -U.
-            nc = if cfg!(target_os = "macos") {
-                "/usr/bin/nc"
-            } else {
-                "nc"
-            },
-            askpass_socket = askpass_socket.display(),
-            print_args = "printf '%s\\0' \"$@\"",
-            shebang = "#!/bin/sh",
-        );
-        let askpass_script_path = temp_dir.path().join("askpass.sh");
-        fs::write(&askpass_script_path, askpass_script).await?;
-        fs::set_permissions(&askpass_script_path, std::fs::Permissions::from_mode(0o755)).await?;
+        let mut askpass =
+            askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?;
 
         // Start the master SSH process, which does not do anything except for establish
         // the connection and keep it open, allowing other ssh commands to reuse it
@@ -1542,7 +1481,7 @@ impl SshRemoteConnection {
             .stdout(Stdio::piped())
             .stderr(Stdio::piped())
             .env("SSH_ASKPASS_REQUIRE", "force")
-            .env("SSH_ASKPASS", &askpass_script_path)
+            .env("SSH_ASKPASS", &askpass.script_path())
             .args(connection_options.additional_args())
             .args([
                 "-N",
@@ -1556,35 +1495,25 @@ impl SshRemoteConnection {
             .arg(&url)
             .kill_on_drop(true)
             .spawn()?;
-
         // Wait for this ssh process to close its stdout, indicating that authentication
         // has completed.
         let mut stdout = master_process.stdout.take().unwrap();
         let mut output = Vec::new();
-        let connection_timeout = Duration::from_secs(10);
 
         let result = select_biased! {
-            _ = askpass_opened_rx.fuse() => {
-                select_biased! {
-                    stream = askpass_kill_master_rx.fuse() => {
+            result = askpass.run().fuse() => {
+                match result {
+                    AskPassResult::CancelledByUser => {
                         master_process.kill().ok();
-                        drop(stream);
-                        Err(anyhow!("SSH connection canceled"))
+                        Err(anyhow!("SSH connection canceled"))?
                     }
-                    // If the askpass script has opened, that means the user is typing
-                    // their password, in which case we don't want to timeout anymore,
-                    // since we know a connection has been established.
-                    result = stdout.read_to_end(&mut output).fuse() => {
-                        result?;
-                        Ok(())
+                    AskPassResult::Timedout => {
+                        Err(anyhow!("connecting to host timed out"))?
                     }
                 }
             }
             _ = stdout.read_to_end(&mut output).fuse() => {
-                Ok(())
-            }
-            _ = futures::FutureExt::fuse(smol::Timer::after(connection_timeout)) => {
-                Err(anyhow!("Exceeded {:?} timeout trying to connect to host", connection_timeout))
+                anyhow::Ok(())
             }
         };
 
@@ -1592,8 +1521,6 @@ impl SshRemoteConnection {
             return Err(e.context("Failed to connect to host"));
         }
 
-        drop(askpass_task);
-
         if master_process.try_status()?.is_some() {
             output.clear();
             let mut stderr = master_process.stderr.take().unwrap();
@@ -1606,6 +1533,8 @@ impl SshRemoteConnection {
             Err(anyhow!(error_message))?;
         }
 
+        drop(askpass);
+
         let socket = SshSocket {
             connection_options,
             socket_path,
@@ -2558,7 +2487,7 @@ mod fake {
     pub(super) struct Delegate;
 
     impl SshClientDelegate for Delegate {
-        fn ask_password(&self, _: String, _: &mut AsyncApp) -> oneshot::Receiver<Result<String>> {
+        fn ask_password(&self, _: String, _: oneshot::Sender<String>, _: &mut AsyncApp) {
             unreachable!()
         }
 

crates/remote_server/src/headless_project.rs 🔗

@@ -87,8 +87,15 @@ impl HeadlessProject {
             buffer_store
         });
 
-        let git_store =
-            cx.new(|cx| GitStore::new(&worktree_store, buffer_store.clone(), None, None, cx));
+        let git_store = cx.new(|cx| {
+            GitStore::new(
+                &worktree_store,
+                buffer_store.clone(),
+                session.clone().into(),
+                None,
+                cx,
+            )
+        });
         let prettier_store = cx.new(|cx| {
             PrettierStore::new(
                 node_runtime.clone(),

crates/zed/src/main.rs 🔗

@@ -508,7 +508,6 @@ fn main() {
         outline::init(cx);
         project_symbols::init(cx);
         project_panel::init(cx);
-        git_ui::git_panel::init(cx);
         outline_panel::init(cx);
         component_preview::init(cx);
         tasks_ui::init(cx);