From 33cc4b3928c6feec5ee65e2244feea5e7db33df0 Mon Sep 17 00:00:00 2001 From: Harsh Abasaheb Chavan Date: Sun, 15 Mar 2026 04:46:15 +0530 Subject: [PATCH 1/2] Add optional GPG signing status display in commits pane Add a new gui.showGpgSigningStatus config option that, when enabled, shows GPG signature verification status for each commit in the commits view. Uses git's %G? format placeholder to display colored indicators: green check for valid signatures, red X for bad/revoked, yellow tilde for missing keys, and blue dash for unsigned commits. Disabled by default since GPG verification adds latency to commit loading. Closes #5363 Made-with: Cursor --- docs-master/Config.md | 5 +++ pkg/commands/git_commands/commit_loader.go | 37 +++++++++++++++++----- pkg/commands/models/commit.go | 6 ++++ pkg/config/user_config.go | 3 ++ pkg/gui/presentation/commits.go | 17 +++++++++- schema-master/config.json | 5 +++ 6 files changed, 64 insertions(+), 9 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index aa149e9e8..e5ee8ec9c 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -261,6 +261,11 @@ gui: # If true, show commit hashes alongside branch names in the branches view. showBranchCommitHash: false + # If true, show GPG signature verification status for each commit in the + # commits view. This can slow down commit loading as it requires GPG + # verification. + showGpgSigningStatus: false + # Whether to show the divergence from the base branch in the branches view. # One of: 'none' | 'onlyArrow' | 'arrowAndNumber' showDivergenceFromBaseBranch: none diff --git a/pkg/commands/git_commands/commit_loader.go b/pkg/commands/git_commands/commit_loader.go index 8b79bd8cd..abc3011cf 100644 --- a/pkg/commands/git_commands/commit_loader.go +++ b/pkg/commands/git_commands/commit_loader.go @@ -190,11 +190,16 @@ func (self *CommitLoader) MergeRebasingCommits(hashPool *utils.StringPool, commi // example input: // 8ad01fe32fcc20f07bc6693f87aa4977c327f1e1|10 hours ago|Jesse Duffield| (HEAD -> master, tag: v0.15.2)|refresh commits when adding a tag func (self *CommitLoader) extractCommitFromLine(hashPool *utils.StringPool, line string, showDivergence bool) *models.Commit { - split := strings.SplitN(line, "\x00", 8) + showGpg := self.UserConfig().Gui.ShowGpgSigningStatus + numFields := 8 + if showGpg { + numFields = 9 + } + split := strings.SplitN(line, "\x00", numFields) - // Ensure we have the minimum required fields (at least 7 for basic functionality) - if len(split) < 7 { - self.Log.Warnf("Malformed git log line: expected at least 7 fields, got %d. Line: %s", len(split), line) + minFields := 7 + if len(split) < minFields { + self.Log.Warnf("Malformed git log line: expected at least %d fields, got %d. Line: %s", minFields, len(split), line) return nil } @@ -209,10 +214,18 @@ func (self *CommitLoader) extractCommitFromLine(hashPool *utils.StringPool, line } extraInfo := strings.TrimSpace(split[6]) - // message (and the \x00 before it) might not be present if extraInfo is extremely long + gpgStatus := "" + messageIdx := 7 + if showGpg { + if len(split) > 7 { + gpgStatus = split[7] + } + messageIdx = 8 + } + message := "" - if len(split) > 7 { - message = split[7] + if len(split) > messageIdx { + message = split[messageIdx] } var tags []string @@ -248,6 +261,7 @@ func (self *CommitLoader) extractCommitFromLine(hashPool *utils.StringPool, line AuthorEmail: authorEmail, Parents: parents, Divergence: divergence, + GpgStatus: gpgStatus, }) } @@ -586,12 +600,18 @@ func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) *oscommands.CmdObj { refSpec += "..." + opts.RefToShowDivergenceFrom } + showGpg := self.UserConfig().Gui.ShowGpgSigningStatus + format := prettyFormat + if showGpg { + format = prettyFormatWithGpg + } + cmdArgs := NewGitCmd("log"). Arg(refSpec). ArgIf(gitLogOrder != "default", "--"+gitLogOrder). ArgIf(opts.All, "--all"). Arg("--oneline"). - Arg(prettyFormat). + Arg(format). Arg("--abbrev=40"). ArgIf(opts.FilterAuthor != "", "--author="+opts.FilterAuthor). ArgIf(opts.Limit, "-300"). @@ -606,3 +626,4 @@ func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) *oscommands.CmdObj { } const prettyFormat = `--pretty=format:+%H%x00%at%x00%aN%x00%ae%x00%P%x00%m%x00%D%x00%s` +const prettyFormatWithGpg = `--pretty=format:+%H%x00%at%x00%aN%x00%ae%x00%P%x00%m%x00%D%x00%G?%x00%s` diff --git a/pkg/commands/models/commit.go b/pkg/commands/models/commit.go index 137528ee6..1f148ea5a 100644 --- a/pkg/commands/models/commit.go +++ b/pkg/commands/models/commit.go @@ -62,6 +62,10 @@ type Commit struct { Action todo.TodoCommand ActionFlag string // e.g. "-C" for fixup -C Divergence Divergence // set to DivergenceNone unless we are showing the divergence view + + // GPG signature status: "G" good, "B" bad, "U" unknown validity, + // "X" expired, "Y" expired key, "R" revoked, "E" missing key, "N" none + GpgStatus string } type NewCommitOpts struct { @@ -77,6 +81,7 @@ type NewCommitOpts struct { UnixTimestamp int64 Divergence Divergence Parents []string + GpgStatus string } func NewCommit(hashPool *utils.StringPool, opts NewCommitOpts) *Commit { @@ -93,6 +98,7 @@ func NewCommit(hashPool *utils.StringPool, opts NewCommitOpts) *Commit { UnixTimestamp: opts.UnixTimestamp, Divergence: opts.Divergence, parents: lo.Map(opts.Parents, func(s string, _ int) *string { return hashPool.Add(s) }), + GpgStatus: opts.GpgStatus, } } diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 192d13843..66c1a21ea 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -162,6 +162,8 @@ type GuiConfig struct { CommitHashLength int `yaml:"commitHashLength" jsonschema:"minimum=0"` // If true, show commit hashes alongside branch names in the branches view. ShowBranchCommitHash bool `yaml:"showBranchCommitHash"` + // If true, show GPG signature verification status for each commit in the commits view. This can slow down commit loading as it requires GPG verification. + ShowGpgSigningStatus bool `yaml:"showGpgSigningStatus"` // Whether to show the divergence from the base branch in the branches view. // One of: 'none' | 'onlyArrow' | 'arrowAndNumber' ShowDivergenceFromBaseBranch string `yaml:"showDivergenceFromBaseBranch" jsonschema:"enum=none,enum=onlyArrow,enum=arrowAndNumber"` @@ -804,6 +806,7 @@ func GetDefaultConfig() *UserConfig { CommitAuthorLongLength: 17, CommitHashLength: 8, ShowBranchCommitHash: false, + ShowGpgSigningStatus: false, ShowDivergenceFromBaseBranch: "none", CommandLogSize: 8, SplitDiff: "auto", diff --git a/pkg/gui/presentation/commits.go b/pkg/gui/presentation/commits.go index 67fa62ac8..bf8c9f3aa 100644 --- a/pkg/gui/presentation/commits.go +++ b/pkg/gui/presentation/commits.go @@ -393,6 +393,20 @@ func displayCommit( actionString = actionColorMap(commit.Action, commit.Status).Sprint(actionStr) } + gpgString := "" + if common.UserConfig().Gui.ShowGpgSigningStatus { + switch commit.GpgStatus { + case "G", "U": + gpgString = style.FgGreen.Sprint("✓") + case "B", "R", "X", "Y": + gpgString = style.FgRed.Sprint("✗") + case "E": + gpgString = style.FgYellow.Sprint("~") + default: + gpgString = style.FgBlue.Sprint("-") + } + } + tagString := "" if fullDescription { if commit.ExtraInfo != "" { @@ -439,11 +453,12 @@ func displayCommit( } author := authors.AuthorWithLength(commit.AuthorName, authorLength) - cols := make([]string, 0, 7) + cols := make([]string, 0, 8) cols = append( cols, divergenceString, hashString, + gpgString, bisectString, descriptionString, actionString, diff --git a/schema-master/config.json b/schema-master/config.json index 54f9fa9ec..b0a04c320 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -697,6 +697,11 @@ "description": "If true, show commit hashes alongside branch names in the branches view.", "default": false }, + "showGpgSigningStatus": { + "type": "boolean", + "description": "If true, show GPG signature verification status for each commit in the commits view. This can slow down commit loading as it requires GPG verification.", + "default": false + }, "showDivergenceFromBaseBranch": { "type": "string", "enum": [ From 4a0446f9e0cfc7a80e06d82dbf600faadc151f45 Mon Sep 17 00:00:00 2001 From: Harsh Abasaheb Chavan Date: Sun, 12 Apr 2026 17:48:49 +0530 Subject: [PATCH 2/2] Fix GPG status parsing for hydrated commits Pass the GPG parsing mode explicitly so rebasing and sequencer commits keep their subject lines when signature status is enabled. Add regression tests for commit loading, TODO hydration, and commit row rendering. --- pkg/commands/git_commands/commit_loader.go | 19 +++-- .../git_commands/commit_loader_test.go | 85 ++++++++++++++++++- pkg/gui/presentation/commits_test.go | 20 +++++ 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/pkg/commands/git_commands/commit_loader.go b/pkg/commands/git_commands/commit_loader.go index abc3011cf..ded11e771 100644 --- a/pkg/commands/git_commands/commit_loader.go +++ b/pkg/commands/git_commands/commit_loader.go @@ -91,8 +91,9 @@ func (self *CommitLoader) GetCommits(opts GetCommitsOptions) ([]*models.Commit, defer wg.Done() var realCommits []*models.Commit + showGpg := self.UserConfig().Gui.ShowGpgSigningStatus realCommits, logErr = loadCommits(self.getLogCmd(opts), opts.FilterPath, func(line string) (*models.Commit, bool) { - return self.extractCommitFromLine(opts.HashPool, line, opts.RefToShowDivergenceFrom != ""), false + return self.extractCommitFromLine(opts.HashPool, line, opts.RefToShowDivergenceFrom != "", showGpg), false }) if logErr == nil { commits = append(commits, realCommits...) @@ -189,8 +190,7 @@ func (self *CommitLoader) MergeRebasingCommits(hashPool *utils.StringPool, commi // then puts them into a commit object // example input: // 8ad01fe32fcc20f07bc6693f87aa4977c327f1e1|10 hours ago|Jesse Duffield| (HEAD -> master, tag: v0.15.2)|refresh commits when adding a tag -func (self *CommitLoader) extractCommitFromLine(hashPool *utils.StringPool, line string, showDivergence bool) *models.Commit { - showGpg := self.UserConfig().Gui.ShowGpgSigningStatus +func (self *CommitLoader) extractCommitFromLine(hashPool *utils.StringPool, line string, showDivergence bool, showGpg bool) *models.Commit { numFields := 8 if showGpg { numFields = 9 @@ -299,10 +299,16 @@ func (self *CommitLoader) getHydratedTodoCommits(hashPool *utils.StringPool, tod // note that we're not filtering these as we do non-rebasing commits just because // I suspect that will cause some damage + showGpg := self.UserConfig().Gui.ShowGpgSigningStatus + format := prettyFormat + if showGpg { + format = prettyFormatWithGpg + } + cmdObj := self.cmd.New( NewGitCmd("show"). Config("log.showSignature=false"). - Arg("--no-patch", "--oneline", "--abbrev=20", prettyFormat). + Arg("--no-patch", "--oneline", "--abbrev=20", format). Arg(commitHashes...). ToArgv(), ).DontLog() @@ -312,7 +318,10 @@ func (self *CommitLoader) getHydratedTodoCommits(hashPool *utils.StringPool, tod if line == "" || line[0] != '+' { return false, nil } - commit := self.extractCommitFromLine(hashPool, line[1:], false) + commit := self.extractCommitFromLine(hashPool, line[1:], false, showGpg) + if commit == nil { + return false, nil + } fullCommits[commit.Hash()] = commit return false, nil }) diff --git a/pkg/commands/git_commands/commit_loader_test.go b/pkg/commands/git_commands/commit_loader_test.go index 7f9873b0b..d7d84c4bf 100644 --- a/pkg/commands/git_commands/commit_loader_test.go +++ b/pkg/commands/git_commands/commit_loader_test.go @@ -27,6 +27,8 @@ var commitsOutput = strings.ReplaceAll(`+0eea75e8c631fba6b58135697835d58ba4c18db var singleCommitOutput = strings.ReplaceAll(`+0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com|b21997d6b4cbdf84b149|>|HEAD -> better-tests|better typing for rebase mode`, "|", "\x00") +var singleCommitOutputWithGpg = strings.ReplaceAll(`+0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com|b21997d6b4cbdf84b149|>|HEAD -> better-tests|G|better typing for rebase mode`, "|", "\x00") + func TestGetCommits(t *testing.T) { type scenario struct { testName string @@ -34,6 +36,7 @@ func TestGetCommits(t *testing.T) { expectedCommitOpts []models.NewCommitOpts expectedError error logOrder string + showGpg bool opts GetCommitsOptions mainBranches []string } @@ -61,6 +64,30 @@ func TestGetCommits(t *testing.T) { expectedCommitOpts: []models.NewCommitOpts{}, expectedError: nil, }, + { + testName: "should return commits with gpg status when enabled", + logOrder: "topo-order", + showGpg: true, + opts: GetCommitsOptions{RefName: "HEAD", RefForPushedStatus: &models.Branch{Name: "mybranch"}, IncludeRebaseCommits: false}, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"rev-list", "refs/heads/mybranch", "^mybranch@{u}"}, "0eea75e8c631fba6b58135697835d58ba4c18dbc\n", nil). + ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:+%H%x00%at%x00%aN%x00%ae%x00%P%x00%m%x00%D%x00%G?%x00%s", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutputWithGpg, nil), + + expectedCommitOpts: []models.NewCommitOpts{{ + Hash: "0eea75e8c631fba6b58135697835d58ba4c18dbc", + Name: "better typing for rebase mode", + Status: models.StatusUnpushed, + Action: models.ActionNone, + Tags: nil, + ExtraInfo: "(HEAD -> better-tests)", + AuthorName: "Jesse Duffield", + AuthorEmail: "jessedduffield@gmail.com", + UnixTimestamp: 1640826609, + Parents: []string{"b21997d6b4cbdf84b149"}, + GpgStatus: "G", + }}, + expectedError: nil, + }, { testName: "should return commits if they are present", logOrder: "topo-order", @@ -300,6 +327,7 @@ func TestGetCommits(t *testing.T) { t.Run(scenario.testName, func(t *testing.T) { common := common.NewDummyCommon() common.UserConfig().Git.Log.Order = scenario.logOrder + common.UserConfig().Gui.ShowGpgSigningStatus = scenario.showGpg cmd := oscommands.NewDummyCmdObjBuilder(scenario.runner) builder := &CommitLoader{ @@ -333,6 +361,42 @@ func TestGetCommits(t *testing.T) { } } +func TestCommitLoader_getHydratedTodoCommitsWithGpgStatus(t *testing.T) { + common := common.NewDummyCommon() + common.UserConfig().Gui.ShowGpgSigningStatus = true + runner := oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"-c", "log.showSignature=false", "show", "--no-patch", "--oneline", "--abbrev=20", prettyFormatWithGpg, "0eea75e8c631fba6b58135697835d58ba4c18dbc"}, singleCommitOutputWithGpg, nil) + + hashPool := &utils.StringPool{} + loader := &CommitLoader{ + Common: common, + cmd: oscommands.NewDummyCmdObjBuilder(runner), + } + + todoCommits := []*models.Commit{models.NewCommit(hashPool, models.NewCommitOpts{ + Hash: "0eea75e8c631fba6b58135697835d58ba4c18dbc", + Status: models.StatusRebasing, + Action: todo.Pick, + })} + + commits, err := loader.getHydratedTodoCommits(hashPool, todoCommits, false) + + assert.NoError(t, err) + assert.Equal(t, []*models.Commit{models.NewCommit(hashPool, models.NewCommitOpts{ + Hash: "0eea75e8c631fba6b58135697835d58ba4c18dbc", + Name: "better typing for rebase mode", + Status: models.StatusRebasing, + Action: todo.Pick, + ExtraInfo: "(HEAD -> better-tests)", + AuthorName: "Jesse Duffield", + AuthorEmail: "jessedduffield@gmail.com", + UnixTimestamp: 1640826609, + Parents: []string{"b21997d6b4cbdf84b149"}, + GpgStatus: "G", + })}, commits) + runner.CheckForMissingCalls() +} + func TestCommitLoader_getConflictedCommitImpl(t *testing.T) { hashPool := &utils.StringPool{} @@ -605,6 +669,7 @@ func TestCommitLoader_extractCommitFromLine(t *testing.T) { testName string line string showDivergence bool + showGpg bool expectedCommit *models.Commit }{ { @@ -759,6 +824,24 @@ func TestCommitLoader_extractCommitFromLine(t *testing.T) { Divergence: models.DivergenceLeft, }), }, + { + testName: "valid line with gpg status", + line: "hash\x00timestamp\x00author\x00email\x00parents\x00<\x00extraInfo\x00G\x00message", + showDivergence: true, + showGpg: true, + expectedCommit: models.NewCommit(hashPool, models.NewCommitOpts{ + Hash: "hash", + Name: "message", + Tags: nil, + ExtraInfo: "(extraInfo)", + UnixTimestamp: 0, + AuthorName: "author", + AuthorEmail: "email", + Parents: []string{"parents"}, + Divergence: models.DivergenceLeft, + GpgStatus: "G", + }), + }, { testName: "empty line", line: "", @@ -785,7 +868,7 @@ func TestCommitLoader_extractCommitFromLine(t *testing.T) { for _, scenario := range scenarios { t.Run(scenario.testName, func(t *testing.T) { - result := loader.extractCommitFromLine(hashPool, scenario.line, scenario.showDivergence) + result := loader.extractCommitFromLine(hashPool, scenario.line, scenario.showDivergence, scenario.showGpg) if scenario.expectedCommit == nil { assert.Nil(t, result) } else { diff --git a/pkg/gui/presentation/commits_test.go b/pkg/gui/presentation/commits_test.go index 1536d420a..adff5ed30 100644 --- a/pkg/gui/presentation/commits_test.go +++ b/pkg/gui/presentation/commits_test.go @@ -40,6 +40,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) { selectedCommitHashPtr *string startIdx int endIdx int + showGpg bool showGraph bool bisectInfo *git_commands.BisectInfo expected string @@ -90,6 +91,24 @@ func TestGetCommitListDisplayStrings(t *testing.T) { hash2 commit2 `), }, + { + testName: "show gpg status when enabled", + commitOpts: []models.NewCommitOpts{ + {Name: "commit1", Hash: "hash1", GpgStatus: "G"}, + {Name: "commit2", Hash: "hash2", GpgStatus: "N"}, + }, + startIdx: 0, + endIdx: 2, + showGpg: true, + showGraph: false, + bisectInfo: git_commands.NewNullBisectInfo(), + cherryPickedCommitHashSet: set.New[string](), + now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + expected: formatExpected(` + hash1 ✓ commit1 + hash2 - commit2 + `), + }, { testName: "show local branch head, except the current branch, main branches, or merged branches", commitOpts: []models.NewCommitOpts{ @@ -546,6 +565,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) { if !focusing || s.focus { t.Run(s.testName, func(t *testing.T) { hashPool := &utils.StringPool{} + common.UserConfig().Gui.ShowGpgSigningStatus = s.showGpg commits := lo.Map(s.commitOpts, func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) })