git: Only prompt for GPG passphrase when gpg-agent cannot supply it (#61265)

Closes #61141
Follow-up https://github.com/zed-industries/zed/pull/58791

# Objective

Currently after https://github.com/zed-industries/zed/pull/58791 landed,
we now always ask for the gpg passphrase on every commit which is not
ideal, since some people have a bigger **ttl** configured so they don't
have/want to re-enter their passphrase everytime on every commit. So we
should cache the signature so the user does not have to re-enter their
passphrase everytime, which also fixes the case for unprotected keys
which obviously don't have a passphrase. So we pre-check if we can sign
the commit if so we should not ask for the passphrase at all.

## Solution

The solution was also suggested inside this
https://github.com/zed-industries/zed/issues/61141 issue, to first check
if the signature was cached, if that is is the case use that to sign the
commit. If that fails we should fallback to the loopback mode and Zed
should prompt for your passphrase once and your signature should be
cached again.

## Testing

**The basic setup for gpg signing**:

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

**Testing the new flow were the signature is cached**:

5. Run `gpgconf --reload gpg-agent` to reset cached signature
6. Make a change and try committing
7. See that it promts for your passphrase
8. enter passphrase
9. Make a change and try committing
10. Notice it does not re-promts for your passphrase

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [ ] 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

**Before** (**Note** before you were prompted everytime when you commit)


https://github.com/user-attachments/assets/0a967bb1-5eff-4224-9e9a-995e411f8d3a

**After** (**Note** you are only prompted once and using the keychain
after instead)


https://github.com/user-attachments/assets/5eee6afb-5972-44d8-afdd-6416a189db32

---

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

Release Notes:

- Git: Fixed the GPG signing passphrase prompt appearing on every commit
even when gpg-agent had the passphrase cached or the key was
unprotected.
This commit is contained in:
Remco Smits authored and GitHub committed 2026-07-20 16:14:26 +00:00
1 parent 69e5f3e09d
commit 8e1f9eabc0
1 file changed
+33 -9
+33 -9
View File
@@ -504,11 +504,17 @@ fn generate_gpg_wrapper_script(
.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.
// 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.
//
// 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
// passphrase goes to fd 3 via a pipe.
Ok(format!(
r#"#!/bin/sh
for arg in "$@"; do
@@ -527,11 +533,29 @@ if [ -z "${{is_signing}}" ]; then
exec {gpg_program} "$@"
fi
# Signing: ask Zed for the passphrase, then hand it to gpg on fd 3.
# Signing. Buffer stdin (the payload) and the first 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
payload="$tmpdir/payload"
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.
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.
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
printf '%s\n' "$passphrase" |
{gpg_program} --pinentry-mode loopback --passphrase-fd 3 "$@" 3<&0 < "$payload"
"#,
))
}