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

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

View file

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

View file

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