Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<div align="center">
<sup>Special thanks to:</sup>

<br>
<br>
<a href="https://www.warp.dev/?utm_source=github&utm_medium=referral&utm_campaign=lazygit_20231023">
Expand Down
9 changes: 8 additions & 1 deletion docs-master/Config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions docs-master/GPG_Signing.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs-master/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions pkg/app/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const (
DaemonKindDropMergeCommit
DaemonKindMoveFixupCommitDown
DaemonKindWriteRebaseTodo
DaemonKindGpgWrapper
)

const (
Expand All @@ -61,6 +62,7 @@ func getInstruction() Instruction {
DaemonKindMoveTodosDown: deserializeInstruction[*MoveTodosDownInstruction],
DaemonKindInsertBreak: deserializeInstruction[*InsertBreakInstruction],
DaemonKindWriteRebaseTodo: deserializeInstruction[*WriteRebaseTodoInstruction],
DaemonKindGpgWrapper: deserializeInstruction[*GpgWrapperInstruction],
}

return mapping[getDaemonKind()](jsonData)
Expand Down Expand Up @@ -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()
}
52 changes: 46 additions & 6 deletions pkg/app/entry_point.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand All @@ -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

Expand Down
16 changes: 16 additions & 0 deletions pkg/commands/git_commands/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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())
}

Expand All @@ -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()

Expand All @@ -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)
Expand All @@ -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).
Expand Down Expand Up @@ -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)
Expand Down
Loading