git: Add a gpg wrapper script to route GnuPG prompts via the askpass UI (#58791)

This PR adds support for entering your git GPG passphrase through the
askpass UI. As a daily Zed user I'm really missing this feature, this
forces me to switch to a terminal or an external application. which is
not ideal for me and most people that are forced to use GPG signing.

Right now Zed can show a similar error (redacted) when you are trying to
commit with GPG signing enabled:
```
error: gpg failed to sign the data:
[GNUPG:] KEY_CONSIDERED <redacted> 2
[GNUPG:] BEGIN_SIGNING H8
[GNUPG:] PINENTRY_LAUNCHED 4625 curses 1.3.2 - xterm-ghostty - - 501/20 0
gpg: signing failed: Inappropriate ioctl for device
[GNUPG:] FAILURE sign <redacted>
gpg: signing failed: Inappropriate ioctl for device

fatal: failed to write commit object
```

Here is the key configurion parts of my **global** git config:

```
[user]
	name = Remco Smits
	email = <redacted>
	signingkey = <redacted>
[commit]
	gpgsign = true
[tag]
	gpgsign = true
```

**Before**


https://github.com/user-attachments/assets/60a06574-92f1-45df-a29a-8ae11db6751d

**After**


https://github.com/user-attachments/assets/92b648e9-b538-4ce5-af4c-136689d14e1a


**How to test this?**
1. Create a key `gpg --full-generate-key`
2. Run `git config --global user.signingkey <KEY ID>`
3. Run `git config --global commit.gpgsign true`
4. Run & copy result from `gpg --armor --export <KEY ID>` and submit
your public key to github
[https://github.com/settings/gpg/new](https://github.com/settings/gpg/new)
5. Make a change and try committing
6. See that it promts for your passphrase :)

**Note**: I used AI as an assistant to write this PR

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Git: Passphrase prompts from GPG to unlock commit signing keys are now
shown in Zed.

Co-authored-by: Lukas Wirth <lukas@zed.dev>
This commit is contained in:
Remco SmitsandLukas Wirth authored and GitHub committed 2026-07-15 08:27:31 +00:00
1 parent 166f044fd0
commit b76e4db15d
4 files changed
+150

No files matched your search

Generated
+1
View File
@@ -954,6 +954,7 @@ dependencies = [
"smol",
"tempfile",
"util",
"which 6.0.3",
"windows 0.61.3",
"zeroize",
]
+3
View File
@@ -22,6 +22,9 @@ tempfile.workspace = true
util.workspace = true
zeroize.workspace = true
[target.'cfg(not(target_os = "windows"))'.dependencies]
which.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
windows.workspace = true
+136
View File
@@ -91,6 +91,9 @@ pub struct AskPassSession {
#[cfg(not(target_os = "windows"))]
const ASKPASS_SCRIPT_NAME: &str = "askpass.sh";
#[cfg(not(target_os = "windows"))]
const GPG_WRAPPER_SCRIPT_NAME: &str = "gpg-wrapper.sh";
impl AskPassSession {
/// This will create a new AskPassSession.
/// You must retain this session until the master process exits.
@@ -189,6 +192,15 @@ impl AskPassSession {
self.askpass_task.script_path()
}
/// Path to a script suitable for git's `gpg.program`, routing GnuPG
/// passphrase prompts through Zed's askpass UI. `None` if unavailable.
pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> {
#[cfg(not(target_os = "windows"))]
return self.askpass_task.gpg_wrapper_path();
#[cfg(target_os = "windows")]
return None;
}
/// Returns the socket path to set as ZED_ASKPASS_SOCKET.
///
/// On Windows, SSH_ASKPASS points directly to cli.exe. SSH passes only
@@ -206,6 +218,8 @@ pub struct PasswordProxy {
/// On Unix: path to the generated .sh askpass script (set as SSH_ASKPASS).
/// On Windows: path to cli.exe (set as SSH_ASKPASS directly — no script needed).
askpass_script_path: std::path::PathBuf,
#[cfg(not(target_os = "windows"))]
gpg_wrapper_script_path: Option<std::path::PathBuf>,
/// On Windows only: path to the Unix socket, passed as ZED_ASKPASS_SOCKET
/// so cli.exe can find it without --askpass argument parsing.
#[cfg(target_os = "windows")]
@@ -238,6 +252,24 @@ impl PasswordProxy {
let askpass_socket_path = askpass_socket.clone();
// Create a gpg wrapper script that routes GnuPG passphrase prompts through
// the same socket (and thus through Zed's askpass UI). This only works on
// Unix where we control the pinentry via loopback mode. We compute the path
// before the socket task takes ownership of `temp_dir`, and write the file
// afterwards.
#[cfg(not(target_os = "windows"))]
let (gpg_wrapper_script_path, gpg_wrapper_script) =
match generate_gpg_wrapper_script(askpass_program, &askpass_socket_path) {
Ok(script) => (
Some(temp_dir.path().join(GPG_WRAPPER_SCRIPT_NAME)),
Some(script),
),
Err(err) => {
log::warn!("could not create gpg askpass wrapper: {err:#}");
(None, None)
}
};
let _task = executor.spawn(async move {
maybe!(async move {
let listener =
@@ -291,9 +323,36 @@ impl PasswordProxy {
})?;
}
// Write the gpg wrapper script (computed above) and mark it executable.
#[cfg(not(target_os = "windows"))]
let gpg_wrapper_script_path =
if let Some((path, script)) = gpg_wrapper_script_path.zip(gpg_wrapper_script) {
match async {
fs::write(&path, script)
.await
.with_context(|| format!("creating gpg wrapper script at {path:?}"))?;
make_file_executable(&path).await.with_context(|| {
format!("marking gpg wrapper script executable at {path:?}")
})?;
anyhow::Ok(())
}
.await
{
Ok(()) => Some(path),
Err(err) => {
log::warn!("could not write gpg askpass wrapper: {err:#}");
None
}
}
} else {
None
};
Ok(Self {
_task,
askpass_script_path,
#[cfg(not(target_os = "windows"))]
gpg_wrapper_script_path,
#[cfg(target_os = "windows")]
askpass_socket_path,
})
@@ -307,6 +366,11 @@ impl PasswordProxy {
pub fn socket_path(&self) -> impl AsRef<OsStr> {
&self.askpass_socket_path
}
#[cfg(not(target_os = "windows"))]
pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> {
self.gpg_wrapper_script_path.as_deref()
}
}
/// Runs Zed in netcat mode for use in askpass.
@@ -398,3 +462,75 @@ fn generate_askpass_script(
"{shebang}\n{print_args} | {askpass_program} --askpass={askpass_socket} 2> /dev/null \n",
))
}
#[inline]
#[cfg(not(target_os = "windows"))]
fn generate_gpg_wrapper_script(
askpass_program: &std::path::Path,
askpass_socket: &std::path::Path,
) -> Result<String> {
let shell_kind = ShellKind::Posix;
let gpg_program = find_gpg_program().context("could not find a gpg binary on PATH")?;
let gpg_program = gpg_program
.to_str()
.context("gpg program is on a non-utf8 path")?;
let gpg_program = shell_kind
.try_quote_prefix_aware(gpg_program)
.context("Failed to shell-escape gpg program path")?;
let askpass_program = shell_kind.prepend_command_prefix(
askpass_program
.to_str()
.context("Askpass program is on a non-utf8 path")?,
);
let askpass_program = shell_kind
.try_quote_prefix_aware(&askpass_program)
.context("Failed to shell-escape Askpass program path")?;
let askpass_socket = askpass_socket
.try_shell_safe(shell_kind)
.context("Failed to shell-escape Askpass socket path")?;
let prompt = shell_kind
.try_quote_prefix_aware("Enter passphrase for your Git signing key:")
.context("Failed to shell-escape gpg passphrase prompt")?;
// The wrapper inspects gpg's arguments and only prompts for a passphrase when
// git asks it to *sign* (e.g. `gpg -bsau <key>`). Other invocations such as
// signature verification (`--verify`) run gpg unchanged so we never pop a
// spurious modal. When signing, we feed the passphrase to gpg on fd 3 via
// `--passphrase-fd 3` in loopback mode, so no pinentry/terminal is required.
Ok(format!(
r#"#!/bin/sh
for arg in "$@"; do
case "$arg" in
# Long-form signing options.
--sign|--detach-sign|--clearsign|--clear-sign) is_signing=1 ;;
# Skip other long options so flags like `--status-fd` don't match below.
--*) ;;
# Short-flag clusters containing `s`, e.g. git's `-bsau`.
-*s*) is_signing=1 ;;
esac
done
# Not a signing request: run gpg as-is, leaving its prompting untouched.
if [ -z "${{is_signing}}" ]; then
exec {gpg_program} "$@"
fi
# Signing: ask Zed for the passphrase, then hand it to gpg on fd 3.
passphrase=$(printf '%s\0' {prompt} | {askpass_program} --askpass={askpass_socket} 2>/dev/null)
exec {gpg_program} --pinentry-mode loopback --passphrase-fd 3 "$@" 3<<EOF
${{passphrase}}
EOF
"#,
))
}
/// Finds the real `gpg` (or `gpg2`) executable on `PATH`.
#[inline]
#[cfg(not(target_os = "windows"))]
fn find_gpg_program() -> Option<std::path::PathBuf> {
["gpg", "gpg2"]
.into_iter()
.find_map(|candidate| which::which(candidate).ok())
}
+10
View File
@@ -3744,6 +3744,16 @@ async fn run_git_command(
.env("GIT_ASKPASS", ask_pass.script_path())
.env("SSH_ASKPASS", ask_pass.script_path())
.env("SSH_ASKPASS_REQUIRE", "force");
if !env.contains_key("GIT_CONFIG_COUNT")
&& let Some(gpg_wrapper) = ask_pass.gpg_wrapper_path()
{
command
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "gpg.program")
.env("GIT_CONFIG_VALUE_0", gpg_wrapper);
}
#[cfg(target_os = "windows")]
command.env("ZED_ASKPASS_SOCKET", ask_pass.socket_path());
let git_process = command.spawn()?;