diff --git a/README.md b/README.md
index 53c02478e57..a9623b8c132 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,6 @@
Special thanks to:
+
diff --git a/docs-master/Config.md b/docs-master/Config.md
index 1d101be1863..43d7a68aeb2 100644
--- a/docs-master/Config.md
+++ b/docs-master/Config.md
@@ -476,7 +476,14 @@ git:
# from within Lazygit with the `(` and `)` keys.
renameSimilarityThreshold: 50
- # If true, do not spawn a separate process when using GPG
+ # If true, do not spawn a separate process when using GPG.
+ # Only enable this if your gpg-agent caches your passphrase or uses a GUI
+ # pinentry (pinentry-gtk-2/pinentry-qt/pinentry-mac); it is ignored (Lazygit
+ # still spawns a subprocess) when your gpg-agent is configured with a
+ # terminal-based pinentry (pinentry-tty/pinentry-curses), since drawing the
+ # prompt directly on the TTY would otherwise corrupt Lazygit's UI.
+ # See docs-master/GPG_Signing.md for more about how Lazygit handles GPG
+ # passphrase prompts.
overrideGpg: false
# If true, do not allow force pushes
diff --git a/docs-master/GPG_Signing.md b/docs-master/GPG_Signing.md
new file mode 100644
index 00000000000..2b8c4942f25
--- /dev/null
+++ b/docs-master/GPG_Signing.md
@@ -0,0 +1,48 @@
+# GPG Signing
+
+## Background
+
+If you have `commit.gpgSign` (or `tag.gpgSign`) enabled in git, git will ask
+gpg to sign your commits/tags, and gpg in turn may need to ask you for your
+key's passphrase via its configured pinentry program. Historically, Lazygit
+had to hand off the whole terminal to a subprocess whenever this could
+happen, because a pinentry program draws its prompt directly onto the
+terminal, which would otherwise corrupt Lazygit's own screen. This meant
+leaving Lazygit's UI entirely (even if gpg-agent had your passphrase cached
+and no prompt was actually needed) and returning to it once the operation
+finished.
+
+## In-app passphrase prompt
+
+If you have GnuPG 2.1 or later, and your gpg-agent hasn't been hardened with
+`no-allow-loopback-pinentry`, Lazygit signs commits and tags using gpg's
+`--pinentry-mode=loopback` option. This makes gpg print a plain textual
+passphrase prompt on its own input/output instead of invoking a pinentry
+program at all. Lazygit detects that prompt the same way it already does for
+SSH passphrase/password prompts, and answers it from its own popup ("Enter
+GPG passphrase") — so you never have to leave Lazygit's UI, and if
+gpg-agent already has your passphrase cached, nothing is shown at all and
+signing happens silently, as before.
+
+This is automatic and requires no configuration. It only falls back to the
+older subprocess/terminal-handoff behavior when loopback mode isn't
+available (e.g. GnuPG < 2.1, or `no-allow-loopback-pinentry` set).
+
+## Seeing when a commit/tag will be signed
+
+Since git applies `commit.gpgSign`/`tag.gpgSign` silently — without ever
+including a flag for it on the command line — Lazygit makes this explicit by
+adding `--gpg-sign` (for commits) or `--sign` (for tags) to the command shown
+in the Command Log whenever the corresponding config option is enabled. This
+is purely for visibility; it doesn't change what gets signed, since it's
+just making git's own implicit config-driven behavior explicit.
+
+## The `overrideGpg` config option
+
+See the `git.overrideGpg` option in [Config.md](./Config.md) if you want
+Lazygit to skip spawning a subprocess for GPG operations even in the cases
+where loopback mode isn't available. This is only safe if your gpg-agent
+caches your passphrase or uses a GUI pinentry (pinentry-gtk-2/pinentry-qt/
+pinentry-mac); Lazygit ignores this setting (and still spawns a subprocess)
+if it detects a terminal-based pinentry (pinentry-tty/pinentry-curses),
+since honoring it in that case would corrupt Lazygit's UI.
diff --git a/docs-master/README.md b/docs-master/README.md
index 1bc0bb6be2e..308e7e8d517 100644
--- a/docs-master/README.md
+++ b/docs-master/README.md
@@ -4,6 +4,7 @@
* [Custom Commands](./Custom_Command_Keybindings.md)
* [Custom Pagers](./Custom_Pagers.md)
* [Dev docs](./dev)
+* [GPG Signing](./GPG_Signing.md)
* [Keybindings](./keybindings)
* [Undo/Redo](./Undoing.md)
* [Range Select](./Range_Select.md)
diff --git a/pkg/app/daemon/daemon.go b/pkg/app/daemon/daemon.go
index df0e4bb4997..fec15535e3f 100644
--- a/pkg/app/daemon/daemon.go
+++ b/pkg/app/daemon/daemon.go
@@ -39,6 +39,7 @@ const (
DaemonKindDropMergeCommit
DaemonKindMoveFixupCommitDown
DaemonKindWriteRebaseTodo
+ DaemonKindGpgWrapper
)
const (
@@ -61,6 +62,7 @@ func getInstruction() Instruction {
DaemonKindMoveTodosDown: deserializeInstruction[*MoveTodosDownInstruction],
DaemonKindInsertBreak: deserializeInstruction[*InsertBreakInstruction],
DaemonKindWriteRebaseTodo: deserializeInstruction[*WriteRebaseTodoInstruction],
+ DaemonKindGpgWrapper: deserializeInstruction[*GpgWrapperInstruction],
}
return mapping[getDaemonKind()](jsonData)
@@ -365,3 +367,40 @@ func (self *WriteRebaseTodoInstruction) run(common *common.Common) error {
return os.WriteFile(path, self.TodosFileContent, 0o644)
})
}
+
+// GpgWrapperInstruction makes lazygit act as a thin wrapper around the real
+// gpg program, inserting `--pinentry-mode=loopback` so that gpg prints a
+// plain textual passphrase prompt on its own stdio instead of invoking a
+// pinentry program. We install this as `gpg.program` (via GIT_CONFIG_*
+// env vars) instead of running the real gpg directly, so that lazygit can
+// prompt for the passphrase from its own popup (see gpg_helper.go) rather
+// than having to hand off the terminal.
+type GpgWrapperInstruction struct {
+ RealGpgProgram string
+}
+
+func NewGpgWrapperInstruction(realGpgProgram string) Instruction {
+ return &GpgWrapperInstruction{RealGpgProgram: realGpgProgram}
+}
+
+func (self *GpgWrapperInstruction) Kind() DaemonKind {
+ return DaemonKindGpgWrapper
+}
+
+func (self *GpgWrapperInstruction) SerializedInstructions() string {
+ return serializeInstruction(self)
+}
+
+func (self *GpgWrapperInstruction) run(common *common.Common) error {
+ args := append([]string{"--pinentry-mode=loopback"}, os.Args[1:]...)
+ cmd := exec.Command(self.RealGpgProgram, args...)
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ // git only cares whether the gpg subprocess exited zero or non-zero, so
+ // returning cmd.Run()'s error as-is (which log.Fatal will report and
+ // exit(1) on) is sufficient; we don't need to propagate its exact exit
+ // code.
+ return cmd.Run()
+}
diff --git a/pkg/app/entry_point.go b/pkg/app/entry_point.go
index a3225e31121..044e2e62f1c 100644
--- a/pkg/app/entry_point.go
+++ b/pkg/app/entry_point.go
@@ -51,9 +51,20 @@ type BuildInfo struct {
}
func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTest) {
- cliArgs := parseCliArgsAndEnvVars()
mergeBuildInfo(buildInfo)
+ // Daemon instructions (e.g. the gpg wrapper) may be invoked with
+ // arguments belonging to the program they're wrapping (e.g. gpg's own
+ // flags like --status-fd), which our own CLI flag parser below doesn't
+ // understand and would reject. So check for daemon mode first, and skip
+ // our own flag parsing entirely in that case.
+ if daemon.InDaemonMode() {
+ runDaemon(buildInfo, integrationTest)
+ return
+ }
+
+ cliArgs := parseCliArgsAndEnvVars()
+
if cliArgs.RepoPath != "" {
if cliArgs.WorkTree != "" || cliArgs.GitDir != "" {
log.Fatal("--path option is incompatible with the --work-tree and --git-dir options")
@@ -159,11 +170,6 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes
log.Fatal(err)
}
- if daemon.InDaemonMode() {
- daemon.Handle(common)
- return
- }
-
if cliArgs.Profile {
go func() {
if err := http.ListenAndServe("localhost:6060", nil); err != nil {
@@ -177,6 +183,40 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes
Run(appConfig, common, appTypes.NewStartArgs(cliArgs.FilterPath, parsedGitArg, cliArgs.ScreenMode, integrationTest))
}
+// runDaemon builds just enough app state to run a daemon instruction (temp
+// dir, AppConfig, Common), deliberately skipping our own CLI flag parsing:
+// some daemon instructions (e.g. the gpg wrapper) are invoked with arguments
+// belonging to the program they wrap, which our flag parser doesn't
+// understand and would reject.
+func runDaemon(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTest) {
+ tempDirBase := getTempDirBase()
+ tempDir, err := os.MkdirTemp(tempDirBase, "lazygit-*")
+ if err != nil {
+ if os.IsPermission(err) {
+ log.Fatalf("Your temp directory (%s) is not writeable. Try if rebooting your machine fixes this.", tempDirBase)
+ }
+
+ log.Fatal(err.Error())
+ }
+ defer os.RemoveAll(tempDir)
+
+ appConfig, err := config.NewAppConfig("lazygit", buildInfo.Version, buildInfo.Commit, buildInfo.Date, buildInfo.BuildSource, false, tempDir)
+ if err != nil {
+ log.Fatal(err.Error())
+ }
+
+ if integrationTest != nil {
+ integrationTest.SetupConfig(appConfig)
+ }
+
+ common, err := NewCommon(appConfig)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ daemon.Handle(common)
+}
+
func parseCliArgsAndEnvVars() *cliArgs {
flaggy.DefaultParser.ShowVersionWithVersionFlag = false
diff --git a/pkg/commands/git_commands/commit.go b/pkg/commands/git_commands/commit.go
index 6abf272b3e7..d6482bdcfb3 100644
--- a/pkg/commands/git_commands/commit.go
+++ b/pkg/commands/git_commands/commit.go
@@ -92,6 +92,7 @@ func (self *CommitCommands) CommitCmdObj(summary string, description string, for
cmdArgs := NewGitCmd("commit").
ArgIf(forceSkipHooks || (skipHookPrefix != "" && strings.HasPrefix(summary, skipHookPrefix)), "--no-verify").
ArgIf(self.signoffFlag() != "", self.signoffFlag()).
+ ArgIf(self.gpgSignFlag() != "", self.gpgSignFlag()).
Arg(messageArgs...).
ToArgv()
@@ -113,6 +114,7 @@ func (self *CommitCommands) CommitInEditorWithMessageFileCmdObj(tmpMessageFile s
Arg("--edit").
Arg("--file="+tmpMessageFile).
ArgIf(self.signoffFlag() != "", self.signoffFlag()).
+ ArgIf(self.gpgSignFlag() != "", self.gpgSignFlag()).
ToArgv())
}
@@ -122,6 +124,7 @@ func (self *CommitCommands) RewordLastCommit(summary string, description string)
cmdArgs := NewGitCmd("commit").
Arg("--allow-empty", "--amend", "--only").
+ ArgIf(self.gpgSignFlag() != "", self.gpgSignFlag()).
Arg(messageArgs...).
ToArgv()
@@ -142,6 +145,7 @@ func (self *CommitCommands) commitMessageArgs(summary string, description string
func (self *CommitCommands) CommitEditorCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("commit").
ArgIf(self.signoffFlag() != "", self.signoffFlag()).
+ ArgIf(self.gpgSignFlag() != "", self.gpgSignFlag()).
ToArgv()
return self.cmd.New(cmdArgs)
@@ -154,6 +158,17 @@ func (self *CommitCommands) signoffFlag() string {
return ""
}
+// gpgSignFlag returns "--gpg-sign" when the user has commit.gpgSign enabled,
+// so that the actual git command shown in the Command Log makes it obvious
+// that the commit will be signed (git would otherwise apply commit.gpgSign
+// silently, without ever displaying the flag).
+func (self *CommitCommands) gpgSignFlag() string {
+ if self.config.IsGpgSignEnabled(CommitGpgSign) {
+ return "--gpg-sign"
+ }
+ return ""
+}
+
func (self *CommitCommands) GetCommitMessage(commitHash string) (string, error) {
cmdArgs := NewGitCmd("log").
Arg("--format=%B", "--max-count=1", commitHash).
@@ -235,6 +250,7 @@ func (self *CommitCommands) AmendHead() error {
func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("commit").
Arg("--amend", "--no-edit", "--allow-empty", "--allow-empty-message").
+ ArgIf(self.gpgSignFlag() != "", self.gpgSignFlag()).
ToArgv()
return self.cmd.New(cmdArgs)
diff --git a/pkg/commands/git_commands/config.go b/pkg/commands/git_commands/config.go
index 19f6dcaf5a7..e2617d5e12f 100644
--- a/pkg/commands/git_commands/config.go
+++ b/pkg/commands/git_commands/config.go
@@ -1,9 +1,15 @@
package git_commands
import (
+ "os"
+ "os/exec"
+ "path/filepath"
"regexp"
+ "strconv"
"strings"
+ "sync"
+ "github.com/jesseduffield/lazygit/pkg/app/daemon"
"github.com/jesseduffield/lazygit/pkg/commands/git_config"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
@@ -19,16 +25,29 @@ type ConfigCommands struct {
*common.Common
gitConfig git_config.IGitConfig
+
+ // usesTerminalPinentryOnce lazily determines, once per instance, whether
+ // the user's gpg-agent is configured to use a terminal-based pinentry
+ // program (pinentry-tty/pinentry-curses).
+ usesTerminalPinentryOnce func() bool
+
+ // canUseGpgLoopbackOnce lazily determines, once per instance, whether we
+ // can sign using `gpg --pinentry-mode=loopback`, which lets us prompt for
+ // the passphrase in our own popup instead of handing off the terminal.
+ canUseGpgLoopbackOnce func() bool
}
func NewConfigCommands(
common *common.Common,
gitConfig git_config.IGitConfig,
) *ConfigCommands {
- return &ConfigCommands{
+ self := &ConfigCommands{
Common: common,
gitConfig: gitConfig,
}
+ self.usesTerminalPinentryOnce = sync.OnceValue(usesTerminalPinentry)
+ self.canUseGpgLoopbackOnce = sync.OnceValue(func() bool { return canUseGpgLoopback(self.GetGpgProgram()) })
+ return self
}
type GpgConfigKey string
@@ -43,7 +62,11 @@ const (
// enter their password every time a GPG action is taken
func (self *ConfigCommands) NeedsGpgSubprocess(key GpgConfigKey) bool {
overrideGpg := self.UserConfig().Git.OverrideGpg
- if overrideGpg {
+ // A terminal-based pinentry (pinentry-tty/pinentry-curses) draws directly
+ // on the TTY, which corrupts Lazygit's own screen buffer unless we hand
+ // off the terminal via a subprocess. In that case we must ignore
+ // overrideGpg, since honoring it would break the UI.
+ if overrideGpg && !self.usesTerminalPinentryOnce() {
return false
}
@@ -54,10 +77,54 @@ func (self *ConfigCommands) NeedsGpgSubprocessForCommit() bool {
return self.NeedsGpgSubprocess(CommitGpgSign)
}
+// IsGpgSignEnabled tells us whether the user has gpg signing enabled for the
+// specified action type (commit.gpgSign or tag.gpgSign), independent of
+// overrideGpg/subprocess considerations.
+func (self *ConfigCommands) IsGpgSignEnabled(key GpgConfigKey) bool {
+ return self.gitConfig.GetBool(string(key))
+}
+
func (self *ConfigCommands) GetGpgTagSign() bool {
return self.gitConfig.GetBool(string(TagGpgSign))
}
+// GetGpgProgram returns the gpg binary git will invoke to sign things,
+// respecting the user's gpg.program override and falling back to git's own
+// default of "gpg".
+func (self *ConfigCommands) GetGpgProgram() string {
+ if program := self.gitConfig.Get("gpg.program"); program != "" {
+ return program
+ }
+
+ return "gpg"
+}
+
+// CanUseGpgLoopback tells us whether we can sign using
+// `gpg --pinentry-mode=loopback`, which makes gpg print a plain textual
+// passphrase prompt on its own stdio instead of invoking a pinentry program.
+// This lets us detect the prompt and answer it from our own popup, so
+// signing never has to hand off the terminal at all. It requires GnuPG 2.1+,
+// and is unavailable if the agent has been hardened with
+// `no-allow-loopback-pinentry`.
+func (self *ConfigCommands) CanUseGpgLoopback() bool {
+ return self.canUseGpgLoopbackOnce()
+}
+
+// AddGpgLoopbackEnvVars arranges for cmdObj (expected to be a `git commit` or
+// `git tag` invocation that may need to sign) to invoke gpg via
+// `--pinentry-mode=loopback`, by overriding gpg.program to re-invoke lazygit
+// itself as a thin wrapper (see daemon.NewGpgWrapperInstruction).
+func (self *ConfigCommands) AddGpgLoopbackEnvVars(cmdObj *oscommands.CmdObj) {
+ cmdObj.AddEnvVars(daemon.ToEnvVars(daemon.NewGpgWrapperInstruction(self.GetGpgProgram()))...)
+ // gpg.program is not passed through a shell by git (unlike e.g.
+ // GIT_EDITOR), so we must use the unquoted executable path here.
+ cmdObj.AddEnvVars(
+ "GIT_CONFIG_COUNT=1",
+ "GIT_CONFIG_KEY_0=gpg.program",
+ "GIT_CONFIG_VALUE_0="+oscommands.GetLazygitExecutablePath(),
+ )
+}
+
func (self *ConfigCommands) GetCoreEditor() string {
return self.gitConfig.Get("core.editor")
}
@@ -194,3 +261,182 @@ func (self *ConfigCommands) GetMergeFF() string {
func (self *ConfigCommands) DropConfigCache() {
self.gitConfig.DropCache()
}
+
+// usesTerminalPinentry determines whether the configured pinentry program is
+// terminal-based (pinentry-tty/pinentry-curses), by asking gpgconf, falling
+// back to reading gpg-agent.conf directly if gpgconf isn't available.
+func usesTerminalPinentry() bool {
+ program := pinentryProgramFromGpgConf()
+ if program == "" {
+ program = pinentryProgramFromGpgAgentConfFile(gpgAgentConfPath())
+ }
+
+ return isTerminalPinentryProgram(program)
+}
+
+// isTerminalPinentryProgram returns true if the given pinentry binary name
+// looks like a terminal-based pinentry program.
+func isTerminalPinentryProgram(program string) bool {
+ if program == "" {
+ return false
+ }
+
+ name := strings.ToLower(filepath.Base(program))
+ return strings.Contains(name, "tty") || strings.Contains(name, "curses")
+}
+
+// pinentryProgramFromGpgConf asks gpgconf for the configured pinentry-program
+// option of gpg-agent, returning "" if it can't be determined.
+func pinentryProgramFromGpgConf() string {
+ output, err := exec.Command("gpgconf", "--list-options", "gpg-agent").Output()
+ if err != nil {
+ return ""
+ }
+
+ return parseGpgConfPinentryProgram(string(output))
+}
+
+// parseGpgConfPinentryProgram parses the output of
+// `gpgconf --list-options gpg-agent`, which consists of colon-separated
+// lines of the form:
+//
+// name:flags:level:description:type:alt-type:argname:default:argdef:value
+//
+// where "value" is percent-encoded if present, and holds the pinentry program
+// path when the user has overridden it.
+func parseGpgConfPinentryProgram(output string) string {
+ for line := range strings.Lines(output) {
+ line = strings.TrimSuffix(line, "\n")
+ fields := strings.Split(line, ":")
+ if len(fields) < 10 || fields[0] != "pinentry-program" {
+ continue
+ }
+
+ return gpgConfUnescape(fields[9])
+ }
+
+ return ""
+}
+
+// gpgConfUnescape decodes the %XX percent-encoding used by gpgconf's
+// colon-separated output format.
+func gpgConfUnescape(s string) string {
+ var builder strings.Builder
+ for i := 0; i < len(s); i++ {
+ if s[i] == '%' && i+2 < len(s) {
+ if b, err := strconv.ParseUint(s[i+1:i+3], 16, 8); err == nil {
+ builder.WriteByte(byte(b))
+ i += 2
+ continue
+ }
+ }
+ builder.WriteByte(s[i])
+ }
+
+ return builder.String()
+}
+
+// canUseGpgLoopback determines whether we can sign using
+// `gpg --pinentry-mode=loopback` with the given gpg program: this requires
+// GnuPG 2.1+, and requires that the agent hasn't been hardened with
+// `no-allow-loopback-pinentry`.
+func canUseGpgLoopback(program string) bool {
+ versionOutput, err := exec.Command(program, "--version").Output()
+ if err != nil {
+ return false
+ }
+
+ major, minor, ok := parseGpgVersion(string(versionOutput))
+ if !ok || major < 2 || (major == 2 && minor < 1) {
+ return false
+ }
+
+ gpgConfOutput, err := exec.Command("gpgconf", "--list-options", "gpg-agent").Output()
+ if err != nil {
+ // if gpgconf isn't available we can't check for
+ // no-allow-loopback-pinentry, but that option is opt-in and rare, so
+ // assume loopback is fine.
+ return true
+ }
+
+ return !parseGpgConfNoAllowLoopbackPinentry(string(gpgConfOutput))
+}
+
+var gpgVersionRe = regexp.MustCompile(`(?m)^gpg \(GnuPG(?:/MacGPG2)?\)\s+(\d+)\.(\d+)`)
+
+// parseGpgVersion extracts the major and minor version numbers from the
+// output of `gpg --version`, whose first line looks like
+// "gpg (GnuPG) 2.2.27" or "gpg (GnuPG/MacGPG2) 2.2.27".
+func parseGpgVersion(output string) (major int, minor int, ok bool) {
+ match := gpgVersionRe.FindStringSubmatch(output)
+ if match == nil {
+ return 0, 0, false
+ }
+
+ major, errMajor := strconv.Atoi(match[1])
+ minor, errMinor := strconv.Atoi(match[2])
+ if errMajor != nil || errMinor != nil {
+ return 0, 0, false
+ }
+
+ return major, minor, true
+}
+
+// parseGpgConfNoAllowLoopbackPinentry parses the output of
+// `gpgconf --list-options gpg-agent` (see parseGpgConfPinentryProgram for the
+// column format) to determine whether the agent has been hardened with
+// `no-allow-loopback-pinentry`, which would make `--pinentry-mode=loopback`
+// fail.
+func parseGpgConfNoAllowLoopbackPinentry(output string) bool {
+ for line := range strings.Lines(output) {
+ line = strings.TrimSuffix(line, "\n")
+ fields := strings.Split(line, ":")
+ if len(fields) < 10 || fields[0] != "no-allow-loopback-pinentry" {
+ continue
+ }
+
+ return gpgConfUnescape(fields[9]) == "1"
+ }
+
+ return false
+}
+
+// gpgAgentConfPath returns the path to the user's gpg-agent.conf, or "" if
+// the home directory can't be determined.
+func gpgAgentConfPath() string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return ""
+ }
+
+ return filepath.Join(home, ".gnupg", "gpg-agent.conf")
+}
+
+// pinentryProgramFromGpgAgentConfFile reads the pinentry-program setting out
+// of a gpg-agent.conf file, returning "" if the file doesn't exist or doesn't
+// set it.
+func pinentryProgramFromGpgAgentConfFile(path string) string {
+ if path == "" {
+ return ""
+ }
+
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+
+ return parseGpgAgentConfPinentryProgram(string(content))
+}
+
+var pinentryProgramLineRe = regexp.MustCompile(`(?m)^\s*pinentry-program\s+(\S+)\s*$`)
+
+// parseGpgAgentConfPinentryProgram extracts the value of the pinentry-program
+// option from the contents of a gpg-agent.conf file.
+func parseGpgAgentConfPinentryProgram(content string) string {
+ match := pinentryProgramLineRe.FindStringSubmatch(content)
+ if match == nil {
+ return ""
+ }
+
+ return match[1]
+}
diff --git a/pkg/commands/git_commands/config_test.go b/pkg/commands/git_commands/config_test.go
index 4ec121aed2d..6bb45e100d4 100644
--- a/pkg/commands/git_commands/config_test.go
+++ b/pkg/commands/git_commands/config_test.go
@@ -125,3 +125,194 @@ func TestGetGitFlowPrefixMap(t *testing.T) {
})
}
}
+
+func TestIsTerminalPinentryProgram(t *testing.T) {
+ scenarios := []struct {
+ testName string
+ program string
+ expected bool
+ }{
+ {testName: "empty", program: "", expected: false},
+ {testName: "gtk", program: "/usr/bin/pinentry-gtk-2", expected: false},
+ {testName: "qt", program: "/usr/bin/pinentry-qt", expected: false},
+ {testName: "mac", program: "/usr/local/bin/pinentry-mac", expected: false},
+ {testName: "plain pinentry (curses fallback binary)", program: "/usr/bin/pinentry", expected: false},
+ {testName: "tty", program: "/usr/bin/pinentry-tty", expected: true},
+ {testName: "curses", program: "/usr/bin/pinentry-curses", expected: true},
+ {testName: "case insensitive", program: "/usr/bin/Pinentry-TTY", expected: true},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ assert.Equal(t, s.expected, isTerminalPinentryProgram(s.program))
+ })
+ }
+}
+
+func TestParseGpgConfPinentryProgram(t *testing.T) {
+ scenarios := []struct {
+ testName string
+ output string
+ expected string
+ }{
+ {
+ testName: "empty output",
+ output: "",
+ expected: "",
+ },
+ {
+ testName: "no pinentry-program line",
+ output: "gpg-agent-name:0:0:0:1:0:0:0:0:\n",
+ expected: "",
+ },
+ {
+ testName: "value unset",
+ output: "pinentry-program:0:24:Pinentry to use for password entry:1:path:0:0:0:\n",
+ expected: "",
+ },
+ {
+ testName: "plain path",
+ output: "pinentry-program:0:24:Pinentry to use for password entry:1:path:0:0:0:/usr/bin/pinentry-curses\n",
+ expected: "/usr/bin/pinentry-curses",
+ },
+ {
+ testName: "percent-encoded path with spaces and colons",
+ output: `pinentry-program:0:24:Pinentry to use for password entry:1:path:0:0:0:/usr/local/my%20agent/pinentry-tty%3av2`,
+ expected: "/usr/local/my agent/pinentry-tty:v2",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ assert.Equal(t, s.expected, parseGpgConfPinentryProgram(s.output))
+ })
+ }
+}
+
+func TestParseGpgAgentConfPinentryProgram(t *testing.T) {
+ scenarios := []struct {
+ testName string
+ content string
+ expected string
+ }{
+ {
+ testName: "empty file",
+ content: "",
+ expected: "",
+ },
+ {
+ testName: "no pinentry-program setting",
+ content: "# a comment\ndefault-cache-ttl 600\n",
+ expected: "",
+ },
+ {
+ testName: "simple setting",
+ content: "pinentry-program /usr/bin/pinentry-curses\n",
+ expected: "/usr/bin/pinentry-curses",
+ },
+ {
+ testName: "setting among other lines, with leading whitespace",
+ content: "default-cache-ttl 600\n pinentry-program /usr/bin/pinentry-tty\nmax-cache-ttl 7200\n",
+ expected: "/usr/bin/pinentry-tty",
+ },
+ {
+ testName: "commented out setting is ignored",
+ content: "# pinentry-program /usr/bin/pinentry-tty\n",
+ expected: "",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ assert.Equal(t, s.expected, parseGpgAgentConfPinentryProgram(s.content))
+ })
+ }
+}
+
+func TestParseGpgVersion(t *testing.T) {
+ scenarios := []struct {
+ testName string
+ output string
+ expectedMajor int
+ expectedMinor int
+ expectedOk bool
+ }{
+ {
+ testName: "empty output",
+ output: "",
+ expectedOk: false,
+ },
+ {
+ testName: "standard GnuPG output",
+ output: "gpg (GnuPG) 2.2.27\nlibgcrypt 1.8.5\n...",
+ expectedMajor: 2,
+ expectedMinor: 2,
+ expectedOk: true,
+ },
+ {
+ testName: "macOS GnuPG/MacGPG2 output",
+ output: "gpg (GnuPG/MacGPG2) 2.4.5\nlibgcrypt 1.10.3\n...",
+ expectedMajor: 2,
+ expectedMinor: 4,
+ expectedOk: true,
+ },
+ {
+ testName: "old 1.x version",
+ output: "gpg (GnuPG) 1.4.23\n...",
+ expectedMajor: 1,
+ expectedMinor: 4,
+ expectedOk: true,
+ },
+ {
+ testName: "unrecognized output",
+ output: "some random program 2.2.27\n",
+ expectedOk: false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ major, minor, ok := parseGpgVersion(s.output)
+ assert.Equal(t, s.expectedOk, ok)
+ if ok {
+ assert.Equal(t, s.expectedMajor, major)
+ assert.Equal(t, s.expectedMinor, minor)
+ }
+ })
+ }
+}
+
+func TestParseGpgConfNoAllowLoopbackPinentry(t *testing.T) {
+ scenarios := []struct {
+ testName string
+ output string
+ expected bool
+ }{
+ {
+ testName: "empty output",
+ output: "",
+ expected: false,
+ },
+ {
+ testName: "option not present",
+ output: "pinentry-program:0:24:Pinentry to use for password entry:1:path:0:0:0:\n",
+ expected: false,
+ },
+ {
+ testName: "option present but unset",
+ output: "no-allow-loopback-pinentry:0:24:Disallow the use of the loopback pinentry:0:0:0:0:0:\n",
+ expected: false,
+ },
+ {
+ testName: "option set",
+ output: "no-allow-loopback-pinentry:0:24:Disallow the use of the loopback pinentry:0:0:0:0:0:1\n",
+ expected: true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ assert.Equal(t, s.expected, parseGpgConfNoAllowLoopbackPinentry(s.output))
+ })
+ }
+}
diff --git a/pkg/commands/git_commands/tag.go b/pkg/commands/git_commands/tag.go
index 4d15027e1df..ea5f96ee714 100644
--- a/pkg/commands/git_commands/tag.go
+++ b/pkg/commands/git_commands/tag.go
@@ -32,11 +32,23 @@ func (self *TagCommands) CreateAnnotatedObj(tagName, ref, msg string, force bool
ArgIf(force, "--force").
ArgIf(len(ref) > 0, ref).
Arg("-m", msg).
+ ArgIf(self.signFlag() != "", self.signFlag()).
ToArgv()
return self.cmd.New(cmdArgs)
}
+// signFlag returns "--sign" when the user has tag.gpgSign enabled, so that
+// the actual git command shown in the Command Log makes it obvious that the
+// tag will be signed (git would otherwise apply tag.gpgSign silently,
+// without ever displaying the flag).
+func (self *TagCommands) signFlag() string {
+ if self.config.IsGpgSignEnabled(TagGpgSign) {
+ return "--sign"
+ }
+ return ""
+}
+
func (self *TagCommands) HasTag(tagName string) bool {
cmdArgs := NewGitCmd("show-ref").
Arg("--tags", "--quiet", "--verify", "--").
diff --git a/pkg/commands/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go
index b70668431f7..92b8c3cf69e 100644
--- a/pkg/commands/oscommands/cmd_obj_runner.go
+++ b/pkg/commands/oscommands/cmd_obj_runner.go
@@ -311,6 +311,7 @@ const (
Password CredentialType = iota
Username
Passphrase
+ GpgPassphrase
PIN
Token
)
@@ -415,6 +416,7 @@ func (self *cmdObjRunner) getCheckForCredentialRequestFunc() func([]byte) (Crede
`Password\s*for\s*'.+':`: Password,
`Username\s*for\s*'.+':`: Username,
`Enter\s*passphrase\s*for\s*key\s*'.+':`: Passphrase,
+ `Enter\s*passphrase:`: GpgPassphrase,
`Enter\s*PIN\s*for\s*.+\s*key\s*.+:`: PIN,
`Enter\s*PIN\s*for\s*'.+':`: PIN,
`.*2FA Token.*`: Token,
diff --git a/pkg/commands/oscommands/os.go b/pkg/commands/oscommands/os.go
index f1ee86c4c9a..a36ee95a009 100644
--- a/pkg/commands/oscommands/os.go
+++ b/pkg/commands/oscommands/os.go
@@ -353,11 +353,21 @@ func (c *OSCommand) GetTempDir() string {
return c.tempDir
}
-// GetLazygitPath returns the path of the currently executed file
-func GetLazygitPath() string {
+// GetLazygitExecutablePath returns the unquoted, slash-normalized path of the
+// currently executing lazygit binary. Use this when the consumer does its own
+// argv-style invocation (e.g. git config values like gpg.program, which git
+// does not pass through a shell). Use GetLazygitPath instead for values that
+// git does invoke via a shell (e.g. GIT_EDITOR).
+func GetLazygitExecutablePath() string {
ex, err := os.Executable() // get the executable path for git to use
if err != nil {
ex = os.Args[0] // fallback to the first call argument if needed
}
- return `"` + filepath.ToSlash(ex) + `"`
+ return filepath.ToSlash(ex)
+}
+
+// GetLazygitPath returns the path of the currently executed file, quoted for
+// use in a shell command line.
+func GetLazygitPath() string {
+ return `"` + GetLazygitExecutablePath() + `"`
}
diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go
index 30ce0377d5a..c019221ffca 100644
--- a/pkg/config/user_config.go
+++ b/pkg/config/user_config.go
@@ -331,7 +331,9 @@ type GitConfig struct {
DiffContextSize uint64 `yaml:"diffContextSize"`
// The threshold for considering a file to be renamed, in percent. Can be changed from within Lazygit with the `(` and `)` keys.
RenameSimilarityThreshold int `yaml:"renameSimilarityThreshold" jsonschema:"minimum=0,maximum=100"`
- // If true, do not spawn a separate process when using GPG
+ // If true, do not spawn a separate process when using GPG.
+ // Only enable this if your gpg-agent caches your passphrase or uses a GUI pinentry (pinentry-gtk-2/pinentry-qt/pinentry-mac); it is ignored (Lazygit still spawns a subprocess) when your gpg-agent is configured with a terminal-based pinentry (pinentry-tty/pinentry-curses), since drawing the prompt directly on the TTY would otherwise corrupt Lazygit's UI.
+ // See docs-master/GPG_Signing.md for more about how Lazygit handles GPG passphrase prompts.
OverrideGpg bool `yaml:"overrideGpg"`
// If true, do not allow force pushes
DisableForcePushing bool `yaml:"disableForcePushing"`
diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go
index e4c5a5f982f..291ce1566a0 100644
--- a/pkg/gocui/view.go
+++ b/pkg/gocui/view.go
@@ -600,11 +600,7 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute) {
return
}
- if v.Mask != "" {
- fgColor = v.FgColor
- bgColor = v.BgColor
- ch = v.Mask
- } else if v.Highlight {
+ if v.Highlight {
rangeSelectStart := v.cy
rangeSelectEnd := v.cy
if v.rangeSelectStartY != -1 {
@@ -1352,7 +1348,8 @@ func (v *View) draw() {
}
// if we're out of cells to write, we'll just print empty cells.
- if cellIdx > len(vline.line)-1 {
+ isContent := cellIdx <= len(vline.line)-1
+ if !isContent {
c = trailingCell
} else {
c = vline.line[cellIdx]
@@ -1370,7 +1367,18 @@ func (v *View) draw() {
fgColor |= AttrUnderline
}
- v.setCharacter(x, y, c.chr, fgColor, bgColor)
+ ch := c.chr
+ // Only mask cells that hold actual typed content; trailing
+ // padding cells stretch to the view's full width regardless of
+ // how much has been typed, so masking them too would make an
+ // empty or partially-filled input look fully populated.
+ if v.Mask != "" && isContent {
+ ch = v.Mask
+ fgColor = v.FgColor
+ bgColor = v.BgColor
+ }
+
+ v.setCharacter(x, y, ch, fgColor, bgColor)
x += c.width
cellIdx++
diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go
index f7e229f1c1b..95a63f11e1a 100644
--- a/pkg/gocui/view_test.go
+++ b/pkg/gocui/view_test.go
@@ -664,3 +664,32 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
"trailing cell at (%d, 2) should have green bg", x)
}
}
+
+// TestMaskDoesNotCoverTrailingPadding verifies that a masked view (as used
+// by the passphrase/password prompt) only replaces cells that hold actual
+// typed content with the mask character. Without this, the trailing padding
+// cells that stretch to the view's full width would also render as the mask
+// character, making an empty or partially-typed input field look like it's
+// already fully populated with a passphrase.
+func TestMaskDoesNotCoverTrailingPadding(t *testing.T) {
+ WithSimulationScreen(t, 14, 5)
+
+ v := NewView("name", 0, 0, 11, 4, OutputNormal)
+ v.Mask = "*"
+
+ v.writeString("ab")
+ v.draw()
+
+ // The two typed characters should render as the mask.
+ for x := 1; x <= 2; x++ {
+ ch, _, _ := Screen.Get(x, 1)
+ assert.Equal(t, "*", ch, "typed cell at (%d, 1) should render as the mask", x)
+ }
+
+ // Everything past the typed content is trailing padding and must NOT
+ // be masked, or an empty/partially-filled field would look full.
+ for x := 3; x <= 10; x++ {
+ ch, _, _ := Screen.Get(x, 1)
+ assert.Equal(t, " ", ch, "trailing cell at (%d, 1) should be blank, not masked", x)
+ }
+}
diff --git a/pkg/gui/controllers/helpers/credentials_helper.go b/pkg/gui/controllers/helpers/credentials_helper.go
index 9b2198ccbf7..4efc5ae72be 100644
--- a/pkg/gui/controllers/helpers/credentials_helper.go
+++ b/pkg/gui/controllers/helpers/credentials_helper.go
@@ -58,6 +58,8 @@ func (self *CredentialsHelper) getTitleAndMask(passOrUname oscommands.Credential
return self.c.Tr.CredentialsPassword, true
case oscommands.Passphrase:
return self.c.Tr.CredentialsPassphrase, true
+ case oscommands.GpgPassphrase:
+ return self.c.Tr.CredentialsGpgPassphrase, true
case oscommands.PIN:
return self.c.Tr.CredentialsPIN, true
case oscommands.Token:
diff --git a/pkg/gui/controllers/helpers/gpg_helper.go b/pkg/gui/controllers/helpers/gpg_helper.go
index fd74a400b8a..fa2d92bbd25 100644
--- a/pkg/gui/controllers/helpers/gpg_helper.go
+++ b/pkg/gui/controllers/helpers/gpg_helper.go
@@ -58,7 +58,13 @@ func (self *GpgHelper) withGpgHandling(
failureRefreshOptions types.RefreshOptions,
successRefreshOptions types.RefreshOptions,
) error {
- useSubprocess := self.c.Git().Config.NeedsGpgSubprocess(configKey)
+ needsGpg := self.c.Git().Config.IsGpgSignEnabled(configKey)
+ if needsGpg && self.c.Git().Config.CanUseGpgLoopback() {
+ return self.runWithLoopbackPinentry(
+ cmdObj, waitingStatus, onSuccess, failureRefreshOptions, successRefreshOptions)
+ }
+
+ useSubprocess := needsGpg && self.c.Git().Config.NeedsGpgSubprocess(configKey)
if useSubprocess {
success, err := self.c.RunSubprocess(cmdObj)
if success && onSuccess != nil {
@@ -79,6 +85,39 @@ func (self *GpgHelper) withGpgHandling(
cmdObj, waitingStatus, onSuccess, failureRefreshOptions, successRefreshOptions)
}
+// runWithLoopbackPinentry runs cmdObj with gpg's `--pinentry-mode=loopback`
+// enabled (via AddGpgLoopbackEnvVars), so that a passphrase prompt appears as
+// plain text on gpg's own stdio. We detect that prompt using the same
+// mechanism as e.g. SSH credential prompts, and answer it from our own popup,
+// so signing never has to hand off the terminal.
+func (self *GpgHelper) runWithLoopbackPinentry(
+ cmdObj *oscommands.CmdObj,
+ waitingStatus string,
+ onSuccess func() error,
+ failureRefreshOptions types.RefreshOptions,
+ successRefreshOptions types.RefreshOptions,
+) error {
+ self.c.Git().Config.AddGpgLoopbackEnvVars(cmdObj)
+
+ return self.c.WithWaitingStatus(waitingStatus, func(task gocui.Task) error {
+ if err := cmdObj.PromptOnCredentialRequest(task).Run(); err != nil {
+ self.c.RefreshFromWorker(failureRefreshOptions)
+ return fmt.Errorf(
+ self.c.Tr.GitCommandFailed, self.c.UserConfig().Keybinding.Universal.ExtrasMenu,
+ )
+ }
+
+ if onSuccess != nil {
+ if err := onSuccess(); err != nil {
+ return err
+ }
+ }
+
+ self.c.RefreshFromWorker(successRefreshOptions)
+ return nil
+ })
+}
+
func (self *GpgHelper) runAndStream(
cmdObj *oscommands.CmdObj,
waitingStatus string,
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index 69ea7012f27..ecbeca38cdc 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -30,6 +30,7 @@ type TranslationSet struct {
CredentialsUsername string
CredentialsPassword string
CredentialsPassphrase string
+ CredentialsGpgPassphrase string
CredentialsPIN string
CredentialsToken string
PassUnameWrong string
@@ -1177,6 +1178,7 @@ func EnglishTranslationSet() *TranslationSet {
CredentialsUsername: "Username",
CredentialsPassword: "Password",
CredentialsPassphrase: "Enter passphrase for SSH key",
+ CredentialsGpgPassphrase: "Enter GPG passphrase",
CredentialsPIN: "Enter PIN for SSH key",
CredentialsToken: "Enter Token for SSH key",
PassUnameWrong: "Password, passphrase and/or username wrong",
@@ -1602,7 +1604,7 @@ func EnglishTranslationSet() *TranslationSet {
DiscardFileChangesTitle: "Discard file changes",
DiscardFileChangesPrompt: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.",
DiscardFileChangesPromptResetPatch: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\n\nNote: This will reset the active custom patch!",
- DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.",
+ DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.\n\nOnly use overrideGpg: true if your gpg-agent caches your passphrase or uses a GUI pinentry (pinentry-gtk-2/pinentry-qt/pinentry-mac). If you use a terminal-based pinentry (pinentry-tty/pinentry-curses), leave this setting as false (default) so Lazygit can properly hand off the terminal.",
CreateRepo: "Not in a git repository. Create a new git repository? (y/N): ",
BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not yet support bare repos. Open most recent repo? (y/n) ",
InitialBranch: "Branch name? (leave empty for git's default): ",
diff --git a/schema-master/config.json b/schema-master/config.json
index 82dbebb0b7f..20783a898b5 100644
--- a/schema-master/config.json
+++ b/schema-master/config.json
@@ -417,7 +417,7 @@
},
"overrideGpg": {
"type": "boolean",
- "description": "If true, do not spawn a separate process when using GPG",
+ "description": "If true, do not spawn a separate process when using GPG.\nOnly enable this if your gpg-agent caches your passphrase or uses a GUI pinentry (pinentry-gtk-2/pinentry-qt/pinentry-mac); it is ignored (Lazygit still spawns a subprocess) when your gpg-agent is configured with a terminal-based pinentry (pinentry-tty/pinentry-curses), since drawing the prompt directly on the TTY would otherwise corrupt Lazygit's UI.\nSee docs-master/GPG_Signing.md for more about how Lazygit handles GPG passphrase prompts.",
"default": false
},
"disableForcePushing": {