diff --git a/docs-master/Config.md b/docs-master/Config.md index 857a4e359..9368cde6b 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -288,6 +288,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..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,12 +190,16 @@ 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 { - split := strings.SplitN(line, "\x00", 8) +func (self *CommitLoader) extractCommitFromLine(hashPool *utils.StringPool, line string, showDivergence bool, showGpg bool) *models.Commit { + 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, }) } @@ -285,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() @@ -298,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 }) @@ -586,12 +609,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 +635,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/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/commands/models/commit.go b/pkg/commands/models/commit.go index 69aca8d73..3f35a5496 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 9738186d9..2955c864b 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -184,6 +184,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"` @@ -914,6 +916,7 @@ func GetDefaultConfigForPlatform(platform string) *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/pkg/gui/presentation/commits_test.go b/pkg/gui/presentation/commits_test.go index 12e0fc1d6..0fa5dbc4b 100644 --- a/pkg/gui/presentation/commits_test.go +++ b/pkg/gui/presentation/commits_test.go @@ -39,6 +39,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) { selectedCommitHashPtr *string startIdx int endIdx int + showGpg bool showGraph bool bisectInfo *git_commands.BisectInfo expected string @@ -89,6 +90,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{ @@ -543,6 +562,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) }) diff --git a/schema-master/config.json b/schema-master/config.json index 45a2b9efe..4e37f4456 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -822,6 +822,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": [