Git askpass (#25953)

Supersedes #25848

Release Notes:

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

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
This commit is contained in:
Conrad Irwin 2025-03-05 22:20:06 -07:00 committed by GitHub
parent 6fdb666bb7
commit c34357e2ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 864 additions and 379 deletions

View file

@ -295,7 +295,10 @@ jobs:
# Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug. # Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug.
- name: Clean CI config file - name: Clean CI config file
if: always() 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. # 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 # 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. # Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug.
- name: Clean CI config file - name: Clean CI config file
if: always() 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: bundle-mac:
timeout-minutes: 120 timeout-minutes: 120

28
Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -760,14 +760,21 @@
"tab": "git_panel::FocusEditor", "tab": "git_panel::FocusEditor",
"shift-tab": "git_panel::FocusEditor", "shift-tab": "git_panel::FocusEditor",
"escape": "git_panel::ToggleFocus", "escape": "git_panel::ToggleFocus",
"cmd-enter": "git::ShowCommitEditor" "cmd-enter": "git::Commit"
} }
}, },
{ {
"context": "GitDiff > Editor", "context": "GitDiff > Editor",
"use_key_equivalents": true, "use_key_equivalents": true,
"bindings": { "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", "cmd-enter": "git::Commit",
"tab": "git_panel::FocusChanges", "tab": "git_panel::FocusChanges",
"shift-tab": "git_panel::FocusChanges", "shift-tab": "git_panel::FocusChanges",
"alt-up": "git_panel::FocusChanges" "alt-up": "git_panel::FocusChanges",
"shift-escape": "git::ExpandCommitEditor"
} }
}, },
{ {

21
crates/askpass/Cargo.toml Normal file
View file

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

View file

@ -0,0 +1 @@
../../LICENSE-APACHE

View file

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

View file

@ -393,9 +393,6 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::OpenContext>) .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::CreateContext>)
.add_request_handler(forward_mutating_project_request::<proto::SynchronizeContexts>) .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::Stage>)
.add_request_handler(forward_mutating_project_request::<proto::Unstage>) .add_request_handler(forward_mutating_project_request::<proto::Unstage>)
.add_request_handler(forward_mutating_project_request::<proto::Commit>) .add_request_handler(forward_mutating_project_request::<proto::Commit>)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
use crate::branch_picker::{self, BranchList}; use crate::branch_picker::{self, BranchList};
use crate::git_panel::{commit_message_editor, GitPanel}; 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 panel::{panel_button, panel_editor_style, panel_filled_button};
use ui::{prelude::*, KeybindingHint, PopoverMenu, Tooltip}; use ui::{prelude::*, KeybindingHint, PopoverMenu, Tooltip};
@ -109,32 +109,36 @@ struct RestoreDock {
impl CommitModal { impl CommitModal {
pub fn register(workspace: &mut Workspace, _: &mut Window, _cx: &mut Context<Workspace>) { pub fn register(workspace: &mut Workspace, _: &mut Window, _cx: &mut Context<Workspace>) {
workspace.register_action(|workspace, _: &ShowCommitEditor, window, cx| { workspace.register_action(|workspace, _: &Commit, window, cx| {
let Some(git_panel) = workspace.panel::<GitPanel>(cx) else { CommitModal::toggle(workspace, window, cx);
return;
};
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)
})
}); });
} }
pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<'_, Workspace>) {
let Some(git_panel) = workspace.panel::<GitPanel>(cx) else {
return;
};
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( fn new(
git_panel: Entity<GitPanel>, git_panel: Entity<GitPanel>,
restore_dock: RestoreDock, restore_dock: RestoreDock,

View file

@ -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::git_panel_settings::StatusStyle;
use crate::remote_output_toast::{RemoteAction, RemoteOutputToast}; use crate::remote_output_toast::{RemoteAction, RemoteOutputToast};
use crate::{ use crate::{
git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
}; };
use crate::{picker_prompt, project_diff, ProjectDiff}; use crate::{picker_prompt, project_diff, ProjectDiff};
use anyhow::Result;
use askpass::AskPassDelegate;
use db::kvp::KEY_VALUE_STORE; use db::kvp::KEY_VALUE_STORE;
use editor::commit_tooltip::CommitTooltip; use editor::commit_tooltip::CommitTooltip;
@ -101,7 +105,7 @@ const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
cx.observe_new( 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.register_action(|workspace, _: &ToggleFocus, window, cx| {
workspace.toggle_panel_focus::<GitPanel>(window, cx); workspace.toggle_panel_focus::<GitPanel>(window, cx);
}); });
@ -1465,13 +1469,19 @@ index 1234567..abcdef0 100644
cx.notify(); 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 { let Some(repo) = self.active_repository.clone() else {
return; return;
}; };
let guard = self.start_remote_operation(); 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 { cx.spawn(|this, mut cx| async move {
let fetch = repo.update(&mut cx, |repo, cx| repo.fetch(askpass, cx))?;
let remote_message = fetch.await?; let remote_message = fetch.await?;
drop(guard); drop(guard);
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
@ -1492,7 +1502,10 @@ index 1234567..abcdef0 100644
.detach_and_log_err(cx); .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 { let Some(repo) = self.active_repository.clone() else {
return; return;
}; };
@ -1501,7 +1514,7 @@ index 1234567..abcdef0 100644
}; };
let branch = branch.clone(); let branch = branch.clone();
let remote = self.get_current_remote(window, cx); 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 { let remote = match remote.await {
Ok(Some(remote)) => remote, Ok(Some(remote)) => remote,
Ok(None) => { 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 let guard = this
.update(&mut cx, |this, _| this.start_remote_operation()) .update(&mut cx, |this, _| this.start_remote_operation())
.ok(); .ok();
let pull = repo.update(&mut cx, |repo, _cx| { let pull = repo.update(&mut cx, |repo, cx| {
repo.pull(branch.name.clone(), remote.name.clone()) repo.pull(branch.name.clone(), remote.name.clone(), askpass, cx)
})?; })?;
let remote_message = pull.await?; let remote_message = pull.await?;
@ -1539,7 +1556,10 @@ index 1234567..abcdef0 100644
.detach_and_log_err(cx); .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 { let Some(repo) = self.active_repository.clone() else {
return; return;
}; };
@ -1547,10 +1567,14 @@ index 1234567..abcdef0 100644
return; return;
}; };
let branch = branch.clone(); 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); 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 { let remote = match remote.await {
Ok(Some(remote)) => remote, Ok(Some(remote)) => remote,
Ok(None) => { 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 let guard = this
.update(&mut cx, |this, _| this.start_remote_operation()) .update(&mut cx, |this, _| this.start_remote_operation())
.ok(); .ok();
let push = repo.update(&mut cx, |repo, _cx| { let push = repo.update(&mut cx, |repo, cx| {
repo.push(branch.name.clone(), remote.name.clone(), options) repo.push(
branch.name.clone(),
remote.name.clone(),
Some(options),
askpass_delegate,
cx,
)
})?; })?;
let remote_output = push.await?; let remote_output = push.await?;
drop(guard); drop(guard);
this.update(&mut cx, |this, cx| match remote_output { this.update(&mut cx, |this, cx| match remote_output {
@ -1590,6 +1623,34 @@ index 1234567..abcdef0 100644
.detach_and_log_err(cx); .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( fn get_current_remote(
&mut self, &mut self,
window: &mut Window, window: &mut Window,
@ -1988,14 +2049,14 @@ index 1234567..abcdef0 100644
}; };
let notif_id = NotificationId::Named("git-operation-error".into()); 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; let toast;
if message.matches("Authentication failed").count() >= 1 { if message
message = format!( .matches(git::repository::REMOTE_CANCELLED_BY_USER)
"{}\n\n{}", .next()
message, "Please set your credentials via the CLI" .is_some()
); {
toast = Toast::new(notif_id, message); return; // Hide the cancelled by user message
} else { } else {
toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| { toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
window.dispatch_action(workspace::OpenLog.boxed_clone(), 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( pub fn render_footer(
&self, &self,
window: &mut Window, window: &mut Window,
@ -2222,7 +2299,7 @@ index 1234567..abcdef0 100644
.on_click(cx.listener({ .on_click(cx.listener({
move |_, _, window, cx| { move |_, _, window, cx| {
window.dispatch_action( window.dispatch_action(
git::ShowCommitEditor.boxed_clone(), git::ExpandCommitEditor.boxed_clone(),
cx, cx,
) )
} }
@ -2840,6 +2917,7 @@ impl Render for GitPanel {
.on_action(cx.listener(Self::unstage_all)) .on_action(cx.listener(Self::unstage_all))
.on_action(cx.listener(Self::restore_tracked_files)) .on_action(cx.listener(Self::restore_tracked_files))
.on_action(cx.listener(Self::clean_all)) .on_action(cx.listener(Self::clean_all))
.on_action(cx.listener(Self::expand_commit_editor))
.when(has_write_access && has_co_authors, |git_panel| { .when(has_write_access && has_co_authors, |git_panel| {
git_panel.on_action(cx.listener(Self::toggle_fill_co_authors)) 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> { 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> { 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("Fetch", git::Fetch.boxed_clone())
.action("Pull", git::Pull.boxed_clone()) .action("Pull", git::Pull.boxed_clone())
.separator() .separator()
.action("Push", git::Push { options: None }.boxed_clone()) .action("Push", git::Push.boxed_clone())
.action( .action("Force Push", git::ForcePush.boxed_clone())
"Force Push",
git::Push {
options: Some(PushOptions::Force),
}
.boxed_clone(),
)
})) }))
}) })
.anchor(Corner::TopRight) .anchor(Corner::TopRight)
@ -3253,14 +3325,14 @@ impl PanelRepoFooter {
move |_, window, cx| { move |_, window, cx| {
if let Some(panel) = panel.as_ref() { if let Some(panel) = panel.as_ref() {
panel.update(cx, |panel, cx| { panel.update(cx, |panel, cx| {
panel.push(&git::Push { options: None }, window, cx); panel.push(false, window, cx);
}); });
} }
}, },
move |window, cx| { move |window, cx| {
git_action_tooltip( git_action_tooltip(
"Push committed changes to remote", "Push committed changes to remote",
&git::Push { options: None }, &git::Push,
"git push", "git push",
panel_focus_handle.clone(), panel_focus_handle.clone(),
window, window,
@ -3289,7 +3361,7 @@ impl PanelRepoFooter {
move |_, window, cx| { move |_, window, cx| {
if let Some(panel) = panel.as_ref() { if let Some(panel) = panel.as_ref() {
panel.update(cx, |panel, cx| { panel.update(cx, |panel, cx| {
panel.pull(&git::Pull, window, cx); panel.pull(window, cx);
}); });
} }
}, },
@ -3319,7 +3391,7 @@ impl PanelRepoFooter {
move |_, window, cx| { move |_, window, cx| {
if let Some(panel) = panel.as_ref() { if let Some(panel) = panel.as_ref() {
panel.update(cx, |panel, cx| { panel.update(cx, |panel, cx| {
panel.fetch(&git::Fetch, window, cx); panel.fetch(window, cx);
}); });
} }
}, },
@ -3349,22 +3421,14 @@ impl PanelRepoFooter {
move |_, window, cx| { move |_, window, cx| {
if let Some(panel) = panel.as_ref() { if let Some(panel) = panel.as_ref() {
panel.update(cx, |panel, cx| { panel.update(cx, |panel, cx| {
panel.push( panel.push(false, window, cx);
&git::Push {
options: Some(PushOptions::SetUpstream),
},
window,
cx,
);
}); });
} }
}, },
move |window, cx| { move |window, cx| {
git_action_tooltip( git_action_tooltip(
"Publish branch to remote", "Publish branch to remote",
&git::Push { &git::Push,
options: Some(PushOptions::SetUpstream),
},
"git push --set-upstream", "git push --set-upstream",
panel_focus_handle.clone(), panel_focus_handle.clone(),
window, window,
@ -3387,22 +3451,14 @@ impl PanelRepoFooter {
move |_, window, cx| { move |_, window, cx| {
if let Some(panel) = panel.as_ref() { if let Some(panel) = panel.as_ref() {
panel.update(cx, |panel, cx| { panel.update(cx, |panel, cx| {
panel.push( panel.push(false, window, cx);
&git::Push {
options: Some(PushOptions::SetUpstream),
},
window,
cx,
);
}); });
} }
}, },
move |window, cx| { move |window, cx| {
git_action_tooltip( git_action_tooltip(
"Re-publish branch to remote", "Re-publish branch to remote",
&git::Push { &git::Push,
options: Some(PushOptions::SetUpstream),
},
"git push --set-upstream", "git push --set-upstream",
panel_focus_handle.clone(), panel_focus_handle.clone(),
window, window,
@ -3417,10 +3473,15 @@ impl PanelRepoFooter {
id: impl Into<SharedString>, id: impl Into<SharedString>,
branch: &Branch, branch: &Branch,
cx: &mut App, 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 id = id.into();
let upstream = branch.upstream.as_ref(); let upstream = branch.upstream.as_ref();
match upstream { Some(match upstream {
Some(Upstream { Some(Upstream {
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }), tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
.. ..
@ -3434,7 +3495,7 @@ impl PanelRepoFooter {
.. ..
}) => self.render_republish_button(id, cx), }) => self.render_republish_button(id, cx),
None => self.render_publish_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)) .child(self.render_overflow_menu(overflow_menu_id))
.when_some(branch, |this, branch| { .when_some(branch, |this, branch| {
let button = self.render_relevant_button(self.id.clone(), &branch, cx); let button = self.render_relevant_button(self.id.clone(), &branch, cx);
this.child(button) this.children(button)
}), }),
) )
} }

View file

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

View file

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

View file

@ -1,22 +1,14 @@
use gpui::{ use gpui::{
AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity,
Task, WeakEntity,
}; };
use itertools::Itertools; use itertools::Itertools;
use picker::{Picker, PickerDelegate}; use picker::{Picker, PickerDelegate};
use project::{ use project::{git::Repository, Project};
git::{GitStore, Repository},
Project,
};
use std::sync::Arc; use std::sync::Arc;
use ui::{prelude::*, ListItem, ListItemSpacing}; use ui::{prelude::*, ListItem, ListItemSpacing};
pub struct RepositorySelector { pub struct RepositorySelector {
picker: Entity<Picker<RepositorySelectorDelegate>>, 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 { impl RepositorySelector {
@ -51,30 +43,7 @@ impl RepositorySelector {
.max_height(Some(rems(20.).into())) .max_height(Some(rems(20.).into()))
}); });
let _subscriptions = RepositorySelector { picker }
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);
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -316,7 +316,7 @@ impl SshPlatform {
} }
pub trait SshClientDelegate: Send + Sync { 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( fn get_download_params(
&self, &self,
platform: SshPlatform, platform: SshPlatform,
@ -1454,83 +1454,22 @@ impl SshRemoteConnection {
delegate: Arc<dyn SshClientDelegate>, delegate: Arc<dyn SshClientDelegate>,
cx: &mut AsyncApp, cx: &mut AsyncApp,
) -> Result<Self> { ) -> Result<Self> {
use futures::AsyncWriteExt as _; use askpass::AskPassResult;
use futures::{io::BufReader, AsyncBufReadExt as _};
use smol::net::unix::UnixStream;
use smol::{fs::unix::PermissionsExt as _, net::unix::UnixListener};
use util::ResultExt as _;
delegate.set_status(Some("Connecting"), cx); delegate.set_status(Some("Connecting"), cx);
let url = connection_options.ssh_url(); let url = connection_options.ssh_url();
let temp_dir = tempfile::Builder::new() let temp_dir = tempfile::Builder::new()
.prefix("zed-ssh-session") .prefix("zed-ssh-session")
.tempdir()?; .tempdir()?;
let askpass_delegate = askpass::AskPassDelegate::new(cx, {
// 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 delegate = delegate.clone(); let delegate = delegate.clone();
|mut cx| async move { move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx)
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;
}
}
}
}
}); });
anyhow::ensure!( let mut askpass =
which::which("nc").is_ok(), askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?;
"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?;
// Start the master SSH process, which does not do anything except for establish // 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 // the connection and keep it open, allowing other ssh commands to reuse it
@ -1542,7 +1481,7 @@ impl SshRemoteConnection {
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.env("SSH_ASKPASS_REQUIRE", "force") .env("SSH_ASKPASS_REQUIRE", "force")
.env("SSH_ASKPASS", &askpass_script_path) .env("SSH_ASKPASS", &askpass.script_path())
.args(connection_options.additional_args()) .args(connection_options.additional_args())
.args([ .args([
"-N", "-N",
@ -1556,35 +1495,25 @@ impl SshRemoteConnection {
.arg(&url) .arg(&url)
.kill_on_drop(true) .kill_on_drop(true)
.spawn()?; .spawn()?;
// Wait for this ssh process to close its stdout, indicating that authentication // Wait for this ssh process to close its stdout, indicating that authentication
// has completed. // has completed.
let mut stdout = master_process.stdout.take().unwrap(); let mut stdout = master_process.stdout.take().unwrap();
let mut output = Vec::new(); let mut output = Vec::new();
let connection_timeout = Duration::from_secs(10);
let result = select_biased! { let result = select_biased! {
_ = askpass_opened_rx.fuse() => { result = askpass.run().fuse() => {
select_biased! { match result {
stream = askpass_kill_master_rx.fuse() => { AskPassResult::CancelledByUser => {
master_process.kill().ok(); 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 AskPassResult::Timedout => {
// their password, in which case we don't want to timeout anymore, Err(anyhow!("connecting to host timed out"))?
// since we know a connection has been established.
result = stdout.read_to_end(&mut output).fuse() => {
result?;
Ok(())
} }
} }
} }
_ = stdout.read_to_end(&mut output).fuse() => { _ = stdout.read_to_end(&mut output).fuse() => {
Ok(()) anyhow::Ok(())
}
_ = futures::FutureExt::fuse(smol::Timer::after(connection_timeout)) => {
Err(anyhow!("Exceeded {:?} timeout trying to connect to host", connection_timeout))
} }
}; };
@ -1592,8 +1521,6 @@ impl SshRemoteConnection {
return Err(e.context("Failed to connect to host")); return Err(e.context("Failed to connect to host"));
} }
drop(askpass_task);
if master_process.try_status()?.is_some() { if master_process.try_status()?.is_some() {
output.clear(); output.clear();
let mut stderr = master_process.stderr.take().unwrap(); let mut stderr = master_process.stderr.take().unwrap();
@ -1606,6 +1533,8 @@ impl SshRemoteConnection {
Err(anyhow!(error_message))?; Err(anyhow!(error_message))?;
} }
drop(askpass);
let socket = SshSocket { let socket = SshSocket {
connection_options, connection_options,
socket_path, socket_path,
@ -2558,7 +2487,7 @@ mod fake {
pub(super) struct Delegate; pub(super) struct Delegate;
impl SshClientDelegate for 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!() unreachable!()
} }

View file

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

View file

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