git: Intercept signing prompt from GPG when committing (#34096)

Closes #30111 

- [x] basic implementation
- [x] implementation for remote projects
- [x] surface error output from GPG if signing fails
- [ ] ~~Windows~~

Release Notes:

- git: Passphrase prompts from GPG to unlock commit signing keys are now
shown in Zed.
This commit is contained in:
Cole Miller 2025-07-10 20:38:51 -04:00 committed by GitHub
parent 87362c602f
commit 842ac984d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 193 additions and 51 deletions

View file

@ -41,9 +41,9 @@ futures.workspace = true
workspace-hack.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true
serde_json.workspace = true
tempfile.workspace = true
text = { workspace = true, features = ["test-support"] }
unindent.workspace = true
gpui = { workspace = true, features = ["test-support"] }
tempfile.workspace = true

View file

@ -391,8 +391,12 @@ pub trait GitRepository: Send + Sync {
message: SharedString,
name_and_email: Option<(SharedString, SharedString)>,
options: CommitOptions,
askpass: AskPassDelegate,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>>;
// This method takes an AsyncApp to ensure it's invoked on the main thread,
// otherwise git-credentials-manager won't work.
cx: AsyncApp,
) -> BoxFuture<'static, Result<()>>;
fn push(
&self,
@ -1193,36 +1197,68 @@ impl GitRepository for RealGitRepository {
message: SharedString,
name_and_email: Option<(SharedString, SharedString)>,
options: CommitOptions,
ask_pass: AskPassDelegate,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>> {
cx: AsyncApp,
) -> BoxFuture<'static, Result<()>> {
let working_directory = self.working_directory();
self.executor
.spawn(async move {
let mut cmd = new_smol_command("git");
cmd.current_dir(&working_directory?)
.envs(env.iter())
.args(["commit", "--quiet", "-m"])
.arg(&message.to_string())
.arg("--cleanup=strip");
let executor = cx.background_executor().clone();
async move {
let working_directory = working_directory?;
let have_user_git_askpass = env.contains_key("GIT_ASKPASS");
let mut command = new_smol_command("git");
command.current_dir(&working_directory).envs(env.iter());
if options.amend {
cmd.arg("--amend");
}
let ask_pass = if have_user_git_askpass {
None
} else {
Some(AskPassSession::new(&executor, ask_pass).await?)
};
if let Some((name, email)) = name_and_email {
cmd.arg("--author").arg(&format!("{name} <{email}>"));
}
if let Some(program) = ask_pass
.as_ref()
.and_then(|ask_pass| ask_pass.gpg_script_path())
{
command.arg("-c").arg(format!(
"gpg.program={}",
program.as_ref().to_string_lossy()
));
}
let output = cmd.output().await?;
command
.args(["commit", "-m"])
.arg(message.to_string())
.arg("--cleanup=strip")
.stdin(smol::process::Stdio::null())
.stdout(smol::process::Stdio::piped())
.stderr(smol::process::Stdio::piped());
if options.amend {
command.arg("--amend");
}
if let Some((name, email)) = name_and_email {
command.arg("--author").arg(&format!("{name} <{email}>"));
}
if let Some(ask_pass) = ask_pass {
command.env("GIT_ASKPASS", ask_pass.script_path());
let git_process = command.spawn()?;
run_askpass_command(ask_pass, git_process).await?;
Ok(())
} else {
let git_process = command.spawn()?;
let output = git_process.output().await?;
anyhow::ensure!(
output.status.success(),
"Failed to commit:\n{}",
"{}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
})
.boxed()
}
}
.boxed()
}
fn push(
@ -2046,12 +2082,16 @@ mod tests {
)
.await
.unwrap();
repo.commit(
"Initial commit".into(),
None,
CommitOptions::default(),
Arc::new(checkpoint_author_envs()),
)
cx.spawn(|cx| {
repo.commit(
"Initial commit".into(),
None,
CommitOptions::default(),
AskPassDelegate::new_always_failing(),
Arc::new(checkpoint_author_envs()),
cx,
)
})
.await
.unwrap();
@ -2075,12 +2115,16 @@ mod tests {
)
.await
.unwrap();
repo.commit(
"Commit after checkpoint".into(),
None,
CommitOptions::default(),
Arc::new(checkpoint_author_envs()),
)
cx.spawn(|cx| {
repo.commit(
"Commit after checkpoint".into(),
None,
CommitOptions::default(),
AskPassDelegate::new_always_failing(),
Arc::new(checkpoint_author_envs()),
cx,
)
})
.await
.unwrap();
@ -2213,12 +2257,16 @@ mod tests {
)
.await
.unwrap();
repo.commit(
"Initial commit".into(),
None,
CommitOptions::default(),
Arc::new(checkpoint_author_envs()),
)
cx.spawn(|cx| {
repo.commit(
"Initial commit".into(),
None,
CommitOptions::default(),
AskPassDelegate::new_always_failing(),
Arc::new(checkpoint_author_envs()),
cx,
)
})
.await
.unwrap();