git: Allow using system pinentry in gpg wrapper script (#62357)

Closes https://github.com/zed-industries/zed/issues/61806

Follow-up to #58791 and #61265.

# Objective

Since Zed started injecting its own gpg wrapper for commit signing, the
wrapper's "silent first attempt" used `--pinentry-mode error`, which
forbids gpg-agent from launching *any* pinentry. That was meant to avoid
the `gpg: signing failed: Inappropriate ioctl for device` failure from
TTY-based pinentries, but it also blocked **GUI** pinentries like
pinentry-mac, which need no TTY and can supply the passphrase silently
from the macOS Keychain. For users with that setup (and gpg-agent's
in-memory cache disabled via `default-cache-ttl 0`), the first attempt
always failed and Zed's passphrase modal appeared on **every commit**,
even though terminal git signed silently.

This also makes the setting proposed in #61533 unnecessary: instead of
asking users to choose between Zed's prompt and the system pinentry, the
wrapper now tries the system pinentry automatically and only falls back
to Zed's askpass modal when gpg genuinely cannot obtain the passphrase
on its own.

## Solution

Restructure the wrapper script to sign in three stages, most silent
first, so every pinentry configuration self-selects the right behavior
without any detection or settings:

1. **`--pinentry-mode error`**: succeeds only via gpg-agent's passphrase
cache or an unprotected key; guaranteed to never prompt anywhere. This
keeps the #61265 behavior byte-identical.
2. **Default pinentry mode**: lets the configured pinentry run, exactly
like terminal git. GUI pinentries (e.g. pinentry-mac reading the macOS
Keychain) need no TTY and sign silently or show their native dialog; TTY
pinentries fail fast with `Inappropriate ioctl for device` because git
spawns gpg without a TTY.
3. **Loopback mode**: asks for the passphrase via Zed's askpass modal
and hands it to gpg on fd 3, for setups where gpg cannot prompt at all
(e.g. when gpg-agent's `pinentry-program` is not configured).

## Testing

Initial steps:
1. Run `security delete-generic-password -s GnuPG` to remove the GnuPG
keychain entry, forcing you to re-enter your passphrase (start from the
initial state of issue)
2. (**optional for a few test cases**) Configure your
`~/.gnupg/gpg-agent.conf` with `pinentry-program
/opt/homebrew/bin/pinentry-mac`
3. Run `gpgconf --kill gpg-agent` to force reset/kill the gpg agent
cache

Cases to test:
1. Have no existing gpg key and you can still commit without needing to
enter a passphrase
2. Have a gpg key configured in git but no `~/.gnupg/gpg-agent.conf`
config that configures the `pinentry-program`
This should still request your passphrase via Zed's askpass modal, even
though no `pinentry-program` is configured and committing via the
terminal throws an error `gpg: signing failed: Inappropriate ioctl for
device`
3. Have a gpg key configured in git and a `~/.gnupg/gpg-agent.conf`
config that configures the `pinentry-program`, and cancel the native
pinentry program → Zed should fall back to requesting your passphrase
via the askpass modal
4. Have a gpg key configured in git and a `~/.gnupg/gpg-agent.conf`
config that configures the `pinentry-program`, and fill in your
passphrase via the native pinentry program with storing your passphrase
in the keychain **disabled**
5. Have a gpg key configured in git and a `~/.gnupg/gpg-agent.conf`
config that configures the `pinentry-program`, and fill in your
passphrase via the native pinentry program with storing your passphrase
in the keychain **enabled** → subsequent commits sign silently, matching
terminal git

## 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 adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

## Showcase

**After: (Testing that the system pinentry program is used and works
without needing to reenter your passphrase everytime only when you
remove it from your keychain)**


https://github.com/user-attachments/assets/0c8cdd11-957d-443a-ab10-1579bbf3b49e

---

Release Notes:

- Git: Fixed the GPG passphrase modal appearing on every commit for
users whose configured pinentry (e.g. pinentry-mac with the macOS
Keychain) can supply the passphrase without Zed's help. Zed now only
prompts when gpg cannot obtain the passphrase on its own.

---------

Co-authored-by: Eric Holk <eric@zed.dev>
This commit is contained in:
Remco SmitsandEric Holk authored and GitHub committed 2026-08-17 22:43:12 +00:00
1 parent 33b0b39e40
commit ea68882d4b
1 file changed
+32 -13
+32 -13
View File
@@ -505,15 +505,23 @@ fn generate_gpg_wrapper_script(
.context("Failed to shell-escape gpg passphrase prompt")?;
// The wrapper only intervenes when git asks gpg to *sign* (e.g. `gpg -bsau
// <key>`); other invocations like `--verify` run unchanged. For signing we
// first try plain gpg so gpg-agent/keychain can supply a cached or empty
// passphrase silently, and only fall back to asking Zed (loopback mode, fd
// 3) when that fails, e.g. the "Inappropriate ioctl for device" case with no
// TTY for pinentry.
// <key>`); other invocations like `--verify` run unchanged. Signing runs in
// three stages, most silent first, so every pinentry configuration works
// without Zed getting in the way:
// 1. `--pinentry-mode error`: succeeds only via gpg-agent's passphrase
// cache or an unprotected key; guaranteed to never prompt anywhere.
// 2. Default pinentry mode: lets the configured pinentry run. GUI
// pinentries (e.g. pinentry-mac reading the macOS Keychain) need no
// TTY and can sign silently or show their native dialog, exactly like
// terminal git; TTY pinentries fail fast ("Inappropriate ioctl for
// device") because git spawns gpg without a TTY.
// 3. Loopback mode with the passphrase from Zed's askpass modal (fd 3),
// for setups where gpg cannot prompt at all.
// (e.g. when gpg agent's `pinentry-program` is not configured)
//
// git streams the payload on stdin (readable once) and reads the signature
// from stdout, so we buffer stdin to replay it into both attempts and buffer
// the first attempt's output, forwarding it only if it succeeds. The
// from stdout, so we buffer stdin to replay it into each attempt and buffer
// each attempt's output, forwarding it only if it succeeds. The
// passphrase goes to fd 3 via a pipe.
Ok(format!(
r#"#!/bin/sh
@@ -533,7 +541,7 @@ if [ -z "${{is_signing}}" ]; then
exec {gpg_program} "$@"
fi
# Signing. Buffer stdin (the payload) and the first attempt's output
# Signing. Buffer stdin (the payload) and each attempt's output
# so we can retry cleanly on failure without git seeing partial output.
tmpdir=$(mktemp -d) || exit 1
trap 'rm -rf "$tmpdir"' EXIT
@@ -542,17 +550,28 @@ signature="$tmpdir/signature"
status="$tmpdir/status"
cat > "$payload" || exit 1
# First try letting gpg-agent/keychain supply the passphrase without any
# interactive pinentry. If that succeeds (cached passphrase)
# forward its output and we're done, so Zed never shows a modal.
# Stage 1: fully silent. Only succeeds if gpg-agent already has the passphrase
# cached or the key is unprotected; `--pinentry-mode error` forbids launching
# any pinentry, so this can never prompt anywhere.
if {gpg_program} --pinentry-mode error "$@" < "$payload" > "$signature" 2> "$status"; then
cat "$status" >&2
cat "$signature"
exit 0
fi
# The silent attempt failed: ask Zed for the passphrase, then hand it to gpg on
# fd 3 using loopback mode so no pinentry/terminal is required.
# Stage 2: let gpg launch the configured pinentry, matching terminal git. GUI
# pinentries work without a TTY and may fetch the passphrase from an OS
# keychain silently (or show their native dialog); TTY pinentries fail fast
# with "Inappropriate ioctl for device" since gpg has no TTY here.
if {gpg_program} "$@" < "$payload" > "$signature" 2> "$status"; then
cat "$status" >&2
cat "$signature"
exit 0
fi
# Stage 3: gpg cannot obtain the passphrase on its own. Ask Zed for it, then
# hand it to gpg on fd 3 using loopback mode so no pinentry/terminal is
# required.
passphrase=$(printf '%s\0' {prompt} | {askpass_program} --askpass={askpass_socket} 2>/dev/null)
printf '%s\n' "$passphrase" |
{gpg_program} --pinentry-mode loopback --passphrase-fd 3 "$@" 3<&0 < "$payload"