From 092c654527d8a7333d4ed72442ddce53ba135946 Mon Sep 17 00:00:00 2001 From: Jan Bronicki Date: Sun, 12 Jul 2026 02:50:17 +0200 Subject: [PATCH 1/7] Fix gpg key prompt Signed-off-by: Jan Bronicki --- docs-master/Config.md | 7 +- pkg/commands/git_commands/config.go | 135 ++++++++++++++++++++++- pkg/commands/git_commands/config_test.go | 103 +++++++++++++++++ pkg/config/user_config.go | 3 +- pkg/i18n/english.go | 2 +- schema-master/config.json | 2 +- 6 files changed, 245 insertions(+), 7 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 1d101be1863..1531e265342 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -476,7 +476,12 @@ 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. overrideGpg: false # If true, do not allow force pushes diff --git a/pkg/commands/git_commands/config.go b/pkg/commands/git_commands/config.go index 19f6dcaf5a7..e190d19e945 100644 --- a/pkg/commands/git_commands/config.go +++ b/pkg/commands/git_commands/config.go @@ -1,8 +1,13 @@ package git_commands import ( + "os" + "os/exec" + "path/filepath" "regexp" + "strconv" "strings" + "sync" "github.com/jesseduffield/lazygit/pkg/commands/git_config" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" @@ -19,6 +24,11 @@ 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 } func NewConfigCommands( @@ -26,8 +36,9 @@ func NewConfigCommands( gitConfig git_config.IGitConfig, ) *ConfigCommands { return &ConfigCommands{ - Common: common, - gitConfig: gitConfig, + Common: common, + gitConfig: gitConfig, + usesTerminalPinentryOnce: sync.OnceValue(usesTerminalPinentry), } } @@ -43,7 +54,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 } @@ -194,3 +209,117 @@ 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() +} + +// 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..7963a2d9e89 100644 --- a/pkg/commands/git_commands/config_test.go +++ b/pkg/commands/git_commands/config_test.go @@ -125,3 +125,106 @@ 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)) + }) + } +} diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 30ce0377d5a..ad7a0b00400 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -331,7 +331,8 @@ 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. OverrideGpg bool `yaml:"overrideGpg"` // If true, do not allow force pushes DisableForcePushing bool `yaml:"disableForcePushing"` diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 69ea7012f27..544e52be464 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -1602,7 +1602,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..337aa7d7290 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.", "default": false }, "disableForcePushing": { From cf5cd3550a8bdf8794ea0a1d7513314d6e5e03c6 Mon Sep 17 00:00:00 2001 From: Jan Bronicki Date: Sun, 12 Jul 2026 16:25:24 +0200 Subject: [PATCH 2/7] Test commit 1 Signed-off-by: Jan Bronicki --- pkg/app/daemon/daemon.go | 39 +++++++ pkg/app/entry_point.go | 52 +++++++-- pkg/commands/git_commands/config.go | 125 +++++++++++++++++++++- pkg/commands/git_commands/config_test.go | 88 +++++++++++++++ pkg/commands/oscommands/cmd_obj_runner.go | 1 + pkg/commands/oscommands/os.go | 16 ++- pkg/gui/controllers/helpers/gpg_helper.go | 41 ++++++- 7 files changed, 348 insertions(+), 14 deletions(-) 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/config.go b/pkg/commands/git_commands/config.go index e190d19e945..e2617d5e12f 100644 --- a/pkg/commands/git_commands/config.go +++ b/pkg/commands/git_commands/config.go @@ -9,6 +9,7 @@ import ( "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" @@ -29,17 +30,24 @@ type ConfigCommands struct { // 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{ - Common: common, - gitConfig: gitConfig, - usesTerminalPinentryOnce: sync.OnceValue(usesTerminalPinentry), + 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 @@ -69,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") } @@ -284,6 +336,71 @@ func gpgConfUnescape(s string) string { 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 { diff --git a/pkg/commands/git_commands/config_test.go b/pkg/commands/git_commands/config_test.go index 7963a2d9e89..6bb45e100d4 100644 --- a/pkg/commands/git_commands/config_test.go +++ b/pkg/commands/git_commands/config_test.go @@ -228,3 +228,91 @@ func TestParseGpgAgentConfPinentryProgram(t *testing.T) { }) } } + +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/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go index b70668431f7..0327f34a08c 100644 --- a/pkg/commands/oscommands/cmd_obj_runner.go +++ b/pkg/commands/oscommands/cmd_obj_runner.go @@ -415,6 +415,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:`: Passphrase, `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/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, From 66be661bd30e2d43506d5c980dce3e8209d28c2d Mon Sep 17 00:00:00 2001 From: Jan Bronicki Date: Sun, 12 Jul 2026 16:30:22 +0200 Subject: [PATCH 3/7] Test commit 2 Signed-off-by: Jan Bronicki --- pkg/commands/oscommands/cmd_obj_runner.go | 3 ++- pkg/gui/controllers/helpers/credentials_helper.go | 2 ++ pkg/i18n/english.go | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/commands/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go index 0327f34a08c..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,7 +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:`: 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/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/i18n/english.go b/pkg/i18n/english.go index 544e52be464..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", From 153e6f7525cf61238a3524a11859b2f4bda897a9 Mon Sep 17 00:00:00 2001 From: Jan Bronicki Date: Sun, 12 Jul 2026 16:41:33 +0200 Subject: [PATCH 4/7] Test commit 3 Signed-off-by: Jan Bronicki --- pkg/commands/git_commands/commit.go | 16 ++++++++++++++++ pkg/commands/git_commands/tag.go | 12 ++++++++++++ 2 files changed, 28 insertions(+) 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/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", "--"). From 0812633b851a34cda28b2f9355dde5ee61f1102f Mon Sep 17 00:00:00 2001 From: Jan Bronicki Date: Sun, 12 Jul 2026 16:47:36 +0200 Subject: [PATCH 5/7] Test commit 4 Signed-off-by: Jan Bronicki --- pkg/gocui/view.go | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) 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++ From fb22c43fc8f44a7dc6c7d45ea9f122f79b6ac46e Mon Sep 17 00:00:00 2001 From: Jan Bronicki Date: Sun, 12 Jul 2026 16:47:57 +0200 Subject: [PATCH 6/7] Test commit 4 Signed-off-by: Jan Bronicki --- README.md | 1 + 1 file changed, 1 insertion(+) 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: +

From 70e29b3bd8388aed096e05ec178d1761ddaa296e Mon Sep 17 00:00:00 2001 From: Jan Bronicki Date: Sun, 12 Jul 2026 17:02:52 +0200 Subject: [PATCH 7/7] Test commit 5 Signed-off-by: Jan Bronicki --- docs-master/Config.md | 2 ++ docs-master/GPG_Signing.md | 48 ++++++++++++++++++++++++++++++++++++++ docs-master/README.md | 1 + pkg/config/user_config.go | 1 + pkg/gocui/view_test.go | 29 +++++++++++++++++++++++ schema-master/config.json | 2 +- 6 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 docs-master/GPG_Signing.md diff --git a/docs-master/Config.md b/docs-master/Config.md index 1531e265342..43d7a68aeb2 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -482,6 +482,8 @@ git: # 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/config/user_config.go b/pkg/config/user_config.go index ad7a0b00400..c019221ffca 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -333,6 +333,7 @@ type GitConfig struct { RenameSimilarityThreshold int `yaml:"renameSimilarityThreshold" jsonschema:"minimum=0,maximum=100"` // 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_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/schema-master/config.json b/schema-master/config.json index 337aa7d7290..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.\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.", + "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": {