1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2024-12-02 09:21:40 +02:00

Add command to show divergence from upstream (#2871)

This commit is contained in:
Stefan Haller 2023-08-29 08:22:44 +02:00 committed by GitHub
commit a63d5891e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 332 additions and 81 deletions

View File

@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -68,6 +69,8 @@ type GetCommitsOptions struct {
RefForPushedStatus string // the ref to use for determining pushed/unpushed status RefForPushedStatus string // the ref to use for determining pushed/unpushed status
// determines if we show the whole git graph i.e. pass the '--all' flag // determines if we show the whole git graph i.e. pass the '--all' flag
All bool All bool
// If non-empty, show divergence from this ref (left-right log)
RefToShowDivergenceFrom string
} }
// GetCommits obtains the commits of the current branch // GetCommits obtains the commits of the current branch
@ -93,17 +96,21 @@ func (self *CommitLoader) GetCommits(opts GetCommitsOptions) ([]*models.Commit,
defer wg.Done() defer wg.Done()
logErr = self.getLogCmd(opts).RunAndProcessLines(func(line string) (bool, error) { logErr = self.getLogCmd(opts).RunAndProcessLines(func(line string) (bool, error) {
commit := self.extractCommitFromLine(line) commit := self.extractCommitFromLine(line, opts.RefToShowDivergenceFrom != "")
commits = append(commits, commit) commits = append(commits, commit)
return false, nil return false, nil
}) })
}) })
var ancestor string var ancestor string
var remoteAncestor string
go utils.Safe(func() { go utils.Safe(func() {
defer wg.Done() defer wg.Done()
ancestor = self.getMergeBase(opts.RefName) ancestor = self.getMergeBase(opts.RefName)
if opts.RefToShowDivergenceFrom != "" {
remoteAncestor = self.getMergeBase(opts.RefToShowDivergenceFrom)
}
}) })
passedFirstPushedCommit := false passedFirstPushedCommit := false
@ -137,8 +144,23 @@ func (self *CommitLoader) GetCommits(opts GetCommitsOptions) ([]*models.Commit,
return commits, nil return commits, nil
} }
if ancestor != "" { if opts.RefToShowDivergenceFrom != "" {
commits = setCommitMergedStatuses(ancestor, commits) sort.SliceStable(commits, func(i, j int) bool {
// In the divergence view we want incoming commits to come first
return commits[i].Divergence > commits[j].Divergence
})
_, localSectionStart, found := lo.FindIndexOf(commits, func(commit *models.Commit) bool {
return commit.Divergence == models.DivergenceLeft
})
if !found {
localSectionStart = len(commits)
}
setCommitMergedStatuses(remoteAncestor, commits[:localSectionStart])
setCommitMergedStatuses(ancestor, commits[localSectionStart:])
} else {
setCommitMergedStatuses(ancestor, commits)
} }
return commits, nil return commits, nil
@ -179,8 +201,8 @@ func (self *CommitLoader) MergeRebasingCommits(commits []*models.Commit) ([]*mod
// then puts them into a commit object // then puts them into a commit object
// example input: // example input:
// 8ad01fe32fcc20f07bc6693f87aa4977c327f1e1|10 hours ago|Jesse Duffield| (HEAD -> master, tag: v0.15.2)|refresh commits when adding a tag // 8ad01fe32fcc20f07bc6693f87aa4977c327f1e1|10 hours ago|Jesse Duffield| (HEAD -> master, tag: v0.15.2)|refresh commits when adding a tag
func (self *CommitLoader) extractCommitFromLine(line string) *models.Commit { func (self *CommitLoader) extractCommitFromLine(line string, showDivergence bool) *models.Commit {
split := strings.SplitN(line, "\x00", 7) split := strings.SplitN(line, "\x00", 8)
sha := split[0] sha := split[0]
unixTimestamp := split[1] unixTimestamp := split[1]
@ -189,6 +211,10 @@ func (self *CommitLoader) extractCommitFromLine(line string) *models.Commit {
extraInfo := strings.TrimSpace(split[4]) extraInfo := strings.TrimSpace(split[4])
parentHashes := split[5] parentHashes := split[5]
message := split[6] message := split[6]
divergence := models.DivergenceNone
if showDivergence {
divergence = lo.Ternary(split[7] == "<", models.DivergenceLeft, models.DivergenceRight)
}
tags := []string{} tags := []string{}
@ -222,6 +248,7 @@ func (self *CommitLoader) extractCommitFromLine(line string) *models.Commit {
AuthorName: authorName, AuthorName: authorName,
AuthorEmail: authorEmail, AuthorEmail: authorEmail,
Parents: parents, Parents: parents,
Divergence: divergence,
} }
} }
@ -251,7 +278,7 @@ func (self *CommitLoader) getHydratedRebasingCommits(rebaseMode enums.RebaseMode
fullCommits := map[string]*models.Commit{} fullCommits := map[string]*models.Commit{}
err = cmdObj.RunAndProcessLines(func(line string) (bool, error) { err = cmdObj.RunAndProcessLines(func(line string) (bool, error) {
commit := self.extractCommitFromLine(line) commit := self.extractCommitFromLine(line, false)
fullCommits[commit.Sha] = commit fullCommits[commit.Sha] = commit
return false, nil return false, nil
}) })
@ -495,7 +522,11 @@ func (self *CommitLoader) commitFromPatch(content string) *models.Commit {
} }
} }
func setCommitMergedStatuses(ancestor string, commits []*models.Commit) []*models.Commit { func setCommitMergedStatuses(ancestor string, commits []*models.Commit) {
if ancestor == "" {
return
}
passedAncestor := false passedAncestor := false
for i, commit := range commits { for i, commit := range commits {
// some commits aren't really commits and don't have sha's, such as the update-ref todo // some commits aren't really commits and don't have sha's, such as the update-ref todo
@ -509,7 +540,6 @@ func setCommitMergedStatuses(ancestor string, commits []*models.Commit) []*model
commits[i].Status = models.StatusMerged commits[i].Status = models.StatusMerged
} }
} }
return commits
} }
func (self *CommitLoader) getMergeBase(refName string) string { func (self *CommitLoader) getMergeBase(refName string) string {
@ -622,8 +652,13 @@ func (self *CommitLoader) getFirstPushedCommit(refName string) (string, error) {
func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj { func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj {
config := self.UserConfig.Git.Log config := self.UserConfig.Git.Log
refSpec := opts.RefName
if opts.RefToShowDivergenceFrom != "" {
refSpec += "..." + opts.RefToShowDivergenceFrom
}
cmdArgs := NewGitCmd("log"). cmdArgs := NewGitCmd("log").
Arg(opts.RefName). Arg(refSpec).
ArgIf(config.Order != "default", "--"+config.Order). ArgIf(config.Order != "default", "--"+config.Order).
ArgIf(opts.All, "--all"). ArgIf(opts.All, "--all").
Arg("--oneline"). Arg("--oneline").
@ -632,6 +667,7 @@ func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj {
ArgIf(opts.Limit, "-300"). ArgIf(opts.Limit, "-300").
ArgIf(opts.FilterPath != "", "--follow"). ArgIf(opts.FilterPath != "", "--follow").
Arg("--no-show-signature"). Arg("--no-show-signature").
ArgIf(opts.RefToShowDivergenceFrom != "", "--left-right").
Arg("--"). Arg("--").
ArgIf(opts.FilterPath != "", opts.FilterPath). ArgIf(opts.FilterPath != "", opts.FilterPath).
ToArgv() ToArgv()
@ -639,4 +675,4 @@ func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj {
return self.cmd.New(cmdArgs).DontLog() return self.cmd.New(cmdArgs).DontLog()
} }
const prettyFormat = `--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s` const prettyFormat = `--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s%x00%m`

View File

@ -45,7 +45,7 @@ func TestGetCommits(t *testing.T) {
opts: GetCommitsOptions{RefName: "HEAD", RefForPushedStatus: "mybranch", IncludeRebaseCommits: false}, opts: GetCommitsOptions{RefName: "HEAD", RefForPushedStatus: "mybranch", IncludeRebaseCommits: false},
runner: oscommands.NewFakeRunner(t). runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil), ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s%x00%m", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
expectedCommits: []*models.Commit{}, expectedCommits: []*models.Commit{},
expectedError: nil, expectedError: nil,
@ -57,7 +57,7 @@ func TestGetCommits(t *testing.T) {
opts: GetCommitsOptions{RefName: "refs/heads/mybranch", RefForPushedStatus: "refs/heads/mybranch", IncludeRebaseCommits: false}, opts: GetCommitsOptions{RefName: "refs/heads/mybranch", RefForPushedStatus: "refs/heads/mybranch", IncludeRebaseCommits: false},
runner: oscommands.NewFakeRunner(t). runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"merge-base", "refs/heads/mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). ExpectGitArgs([]string{"merge-base", "refs/heads/mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
ExpectGitArgs([]string{"log", "refs/heads/mybranch", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil), ExpectGitArgs([]string{"log", "refs/heads/mybranch", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s%x00%m", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
expectedCommits: []*models.Commit{}, expectedCommits: []*models.Commit{},
expectedError: nil, expectedError: nil,
@ -72,7 +72,7 @@ func TestGetCommits(t *testing.T) {
// here it's seeing which commits are yet to be pushed // here it's seeing which commits are yet to be pushed
ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
// here it's actually getting all the commits in a formatted form, one per line // here it's actually getting all the commits in a formatted form, one per line
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, commitsOutput, nil). ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s%x00%m", "--abbrev=40", "--no-show-signature", "--"}, commitsOutput, nil).
// here it's testing which of the configured main branches have an upstream // here it's testing which of the configured main branches have an upstream
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "refs/remotes/origin/master", nil). // this one does ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "refs/remotes/origin/master", nil). // this one does
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "main@{u}"}, "", errors.New("error")). // this one doesn't, so it checks origin instead ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "main@{u}"}, "", errors.New("error")). // this one doesn't, so it checks origin instead
@ -209,7 +209,7 @@ func TestGetCommits(t *testing.T) {
// here it's seeing which commits are yet to be pushed // here it's seeing which commits are yet to be pushed
ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
// here it's actually getting all the commits in a formatted form, one per line // here it's actually getting all the commits in a formatted form, one per line
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil). ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s%x00%m", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil).
// here it's testing which of the configured main branches exist; neither does // here it's testing which of the configured main branches exist; neither does
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "", errors.New("error")). ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "", errors.New("error")).
ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/remotes/origin/master"}, "", errors.New("error")). ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/remotes/origin/master"}, "", errors.New("error")).
@ -246,7 +246,7 @@ func TestGetCommits(t *testing.T) {
// here it's seeing which commits are yet to be pushed // here it's seeing which commits are yet to be pushed
ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
// here it's actually getting all the commits in a formatted form, one per line // here it's actually getting all the commits in a formatted form, one per line
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil). ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s%x00%m", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil).
// here it's testing which of the configured main branches exist // here it's testing which of the configured main branches exist
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "refs/remotes/origin/master", nil). ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "refs/remotes/origin/master", nil).
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "main@{u}"}, "", errors.New("error")). ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "main@{u}"}, "", errors.New("error")).
@ -282,7 +282,7 @@ func TestGetCommits(t *testing.T) {
opts: GetCommitsOptions{RefName: "HEAD", RefForPushedStatus: "mybranch", IncludeRebaseCommits: false}, opts: GetCommitsOptions{RefName: "HEAD", RefForPushedStatus: "mybranch", IncludeRebaseCommits: false},
runner: oscommands.NewFakeRunner(t). runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil), ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s%x00%m", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
expectedCommits: []*models.Commit{}, expectedCommits: []*models.Commit{},
expectedError: nil, expectedError: nil,
@ -294,7 +294,7 @@ func TestGetCommits(t *testing.T) {
opts: GetCommitsOptions{RefName: "HEAD", RefForPushedStatus: "mybranch", FilterPath: "src"}, opts: GetCommitsOptions{RefName: "HEAD", RefForPushedStatus: "mybranch", FilterPath: "src"},
runner: oscommands.NewFakeRunner(t). runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). ExpectGitArgs([]string{"merge-base", "mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--follow", "--no-show-signature", "--", "src"}, "", nil), ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s%x00%m", "--abbrev=40", "--follow", "--no-show-signature", "--", "src"}, "", nil),
expectedCommits: []*models.Commit{}, expectedCommits: []*models.Commit{},
expectedError: nil, expectedError: nil,
@ -548,7 +548,8 @@ func TestCommitLoader_setCommitMergedStatuses(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.testName, func(t *testing.T) { t.Run(scenario.testName, func(t *testing.T) {
expectedCommits := setCommitMergedStatuses(scenario.ancestor, scenario.commits) expectedCommits := scenario.commits
setCommitMergedStatuses(scenario.ancestor, expectedCommits)
assert.Equal(t, scenario.expectedCommits, expectedCommits) assert.Equal(t, scenario.expectedCommits, expectedCommits)
}) })
} }

View File

@ -1,5 +1,7 @@
package models package models
import "fmt"
// Branch : A git branch // Branch : A git branch
// duplicating this for now // duplicating this for now
type Branch struct { type Branch struct {
@ -43,6 +45,22 @@ func (b *Branch) ParentRefName() string {
return b.RefName() + "^" return b.RefName() + "^"
} }
func (b *Branch) FullUpstreamRefName() string {
if b.UpstreamRemote == "" || b.UpstreamBranch == "" {
return ""
}
return fmt.Sprintf("refs/remotes/%s/%s", b.UpstreamRemote, b.UpstreamBranch)
}
func (b *Branch) ShortUpstreamRefName() string {
if b.UpstreamRemote == "" || b.UpstreamBranch == "" {
return ""
}
return fmt.Sprintf("%s/%s", b.UpstreamRemote, b.UpstreamBranch)
}
func (b *Branch) ID() string { func (b *Branch) ID() string {
return b.RefName() return b.RefName()
} }

View File

@ -30,6 +30,17 @@ const (
ActionConflict = todo.Comment + 1 ActionConflict = todo.Comment + 1
) )
type Divergence int
// For a divergence log (left/right comparison of two refs) this is set to
// either DivergenceLeft or DivergenceRight for each commit; for normal
// commit views it is always DivergenceNone.
const (
DivergenceNone Divergence = iota
DivergenceLeft
DivergenceRight
)
// Commit : A git commit // Commit : A git commit
type Commit struct { type Commit struct {
Sha string Sha string
@ -41,6 +52,7 @@ type Commit struct {
AuthorName string // something like 'Jesse Duffield' AuthorName string // something like 'Jesse Duffield'
AuthorEmail string // something like 'jessedduffield@gmail.com' AuthorEmail string // something like 'jessedduffield@gmail.com'
UnixTimestamp int64 UnixTimestamp int64
Divergence Divergence // set to DivergenceNone unless we are showing the divergence view
// SHAs of parent commits (will be multiple if it's a merge commit) // SHAs of parent commits (will be multiple if it's a merge commit)
Parents []string Parents []string

View File

@ -8,7 +8,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo"
) )
type SubCommitsContext struct { type SubCommitsContext struct {
@ -73,12 +73,41 @@ func NewSubCommitsContext(
selectedCommitSha, selectedCommitSha,
startIdx, startIdx,
endIdx, endIdx,
shouldShowGraph(c), // Don't show the graph in the left/right view; we'd like to, but
// it's too complicated:
shouldShowGraph(c) && viewModel.GetRefToShowDivergenceFrom() == "",
git_commands.NewNullBisectInfo(), git_commands.NewNullBisectInfo(),
false, false,
) )
} }
getNonModelItems := func() []*NonModelItem {
result := []*NonModelItem{}
if viewModel.GetRefToShowDivergenceFrom() != "" {
_, upstreamIdx, found := lo.FindIndexOf(
c.Model().SubCommits, func(c *models.Commit) bool { return c.Divergence == models.DivergenceRight })
if !found {
upstreamIdx = 0
}
result = append(result, &NonModelItem{
Index: upstreamIdx,
Content: fmt.Sprintf("--- %s ---", c.Tr.DivergenceSectionHeaderRemote),
})
_, localIdx, found := lo.FindIndexOf(
c.Model().SubCommits, func(c *models.Commit) bool { return c.Divergence == models.DivergenceLeft })
if !found {
localIdx = len(c.Model().SubCommits)
}
result = append(result, &NonModelItem{
Index: localIdx,
Content: fmt.Sprintf("--- %s ---", c.Tr.DivergenceSectionHeaderLocal),
})
}
return result
}
ctx := &SubCommitsContext{ ctx := &SubCommitsContext{
c: c, c: c,
SubCommitsViewModel: viewModel, SubCommitsViewModel: viewModel,
@ -96,6 +125,7 @@ func NewSubCommitsContext(
ListRenderer: ListRenderer{ ListRenderer: ListRenderer{
list: viewModel, list: viewModel,
getDisplayStrings: getDisplayStrings, getDisplayStrings: getDisplayStrings,
getNonModelItems: getNonModelItems,
}, },
c: c, c: c,
refreshViewportOnChange: true, refreshViewportOnChange: true,
@ -112,7 +142,8 @@ func NewSubCommitsContext(
type SubCommitsViewModel struct { type SubCommitsViewModel struct {
// name of the ref that the sub-commits are shown for // name of the ref that the sub-commits are shown for
ref types.Ref ref types.Ref
refToShowDivergenceFrom string
*ListViewModel[*models.Commit] *ListViewModel[*models.Commit]
limitCommits bool limitCommits bool
@ -127,6 +158,14 @@ func (self *SubCommitsViewModel) GetRef() types.Ref {
return self.ref return self.ref
} }
func (self *SubCommitsViewModel) SetRefToShowDivergenceFrom(ref string) {
self.refToShowDivergenceFrom = ref
}
func (self *SubCommitsViewModel) GetRefToShowDivergenceFrom() string {
return self.refToShowDivergenceFrom
}
func (self *SubCommitsViewModel) SetShowBranchHeads(value bool) { func (self *SubCommitsViewModel) SetShowBranchHeads(value bool) {
self.showBranchHeads = value self.showBranchHeads = value
} }
@ -160,10 +199,6 @@ func (self *SubCommitsContext) GetCommits() []*models.Commit {
return self.getModel() return self.getModel()
} }
func (self *SubCommitsContext) Title() string {
return fmt.Sprintf(self.c.Tr.SubCommitsDynamicTitle, utils.TruncateWithEllipsis(self.ref.RefName(), 50))
}
func (self *SubCommitsContext) SetLimitCommits(value bool) { func (self *SubCommitsContext) SetLimitCommits(value bool) {
self.limitCommits = value self.limitCommits = value
} }

View File

@ -76,6 +76,13 @@ func (gui *Gui) resetHelpersAndControllers() {
helperCommon, helperCommon,
func() *status.StatusManager { return gui.statusManager }, func() *status.StatusManager { return gui.statusManager },
) )
setSubCommits := func(commits []*models.Commit) {
gui.Mutexes.SubCommitsMutex.Lock()
defer gui.Mutexes.SubCommitsMutex.Unlock()
gui.State.Model.SubCommits = commits
}
gui.helpers = &helpers.Helpers{ gui.helpers = &helpers.Helpers{
Refs: refsHelper, Refs: refsHelper,
Host: helpers.NewHostHelper(helperCommon), Host: helpers.NewHostHelper(helperCommon),
@ -111,8 +118,9 @@ func (gui *Gui) resetHelpersAndControllers() {
modeHelper, modeHelper,
appStatusHelper, appStatusHelper,
), ),
Search: helpers.NewSearchHelper(helperCommon), Search: helpers.NewSearchHelper(helperCommon),
Worktree: worktreeHelper, Worktree: worktreeHelper,
SubCommits: helpers.NewSubCommitsHelper(helperCommon, refreshHelper, setSubCommits),
} }
gui.CustomCommandsClient = custom_commands.NewClient( gui.CustomCommandsClient = custom_commands.NewClient(
@ -206,13 +214,6 @@ func (gui *Gui) resetHelpersAndControllers() {
controllers.AttachControllers(context, sideWindowControllerFactory.Create(context)) controllers.AttachControllers(context, sideWindowControllerFactory.Create(context))
} }
setSubCommits := func(commits []*models.Commit) {
gui.Mutexes.SubCommitsMutex.Lock()
defer gui.Mutexes.SubCommitsMutex.Unlock()
gui.State.Model.SubCommits = commits
}
for _, context := range []controllers.CanSwitchToSubCommits{ for _, context := range []controllers.CanSwitchToSubCommits{
gui.State.Contexts.Branches, gui.State.Contexts.Branches,
gui.State.Contexts.RemoteBranches, gui.State.Contexts.RemoteBranches,
@ -220,7 +221,7 @@ func (gui *Gui) resetHelpersAndControllers() {
gui.State.Contexts.ReflogCommits, gui.State.Contexts.ReflogCommits,
} { } {
controllers.AttachControllers(context, controllers.NewSwitchToSubCommitsController( controllers.AttachControllers(context, controllers.NewSwitchToSubCommitsController(
common, setSubCommits, context, common, context,
)) ))
} }

View File

@ -9,6 +9,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
) )
@ -141,6 +142,27 @@ func (self *BranchesController) setUpstream(selectedBranch *models.Branch) error
return self.c.Menu(types.CreateMenuOptions{ return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.Actions.SetUnsetUpstream, Title: self.c.Tr.Actions.SetUnsetUpstream,
Items: []*types.MenuItem{ Items: []*types.MenuItem{
{
LabelColumns: []string{self.c.Tr.ViewDivergenceFromUpstream},
OnPress: func() error {
branch := self.context().GetSelected()
if branch == nil {
return nil
}
if !branch.RemoteBranchStoredLocally() {
return self.c.ErrorMsg(self.c.Tr.DivergenceNoUpstream)
}
return self.c.Helpers().SubCommits.ViewSubCommits(helpers.ViewSubCommitsOpts{
Ref: branch,
TitleRef: fmt.Sprintf("%s <-> %s", branch.RefName(), branch.ShortUpstreamRefName()),
RefToShowDivergenceFrom: branch.FullUpstreamRefName(),
Context: self.context(),
ShowBranchHeads: false,
})
},
Key: 'v',
},
{ {
LabelColumns: []string{self.c.Tr.UnsetUpstream}, LabelColumns: []string{self.c.Tr.UnsetUpstream},
OnPress: func() error { OnPress: func() error {

View File

@ -49,6 +49,7 @@ type Helpers struct {
WindowArrangement *WindowArrangementHelper WindowArrangement *WindowArrangementHelper
Search *SearchHelper Search *SearchHelper
Worktree *WorktreeHelper Worktree *WorktreeHelper
SubCommits *SubCommitsHelper
} }
func NewStubHelpers() *Helpers { func NewStubHelpers() *Helpers {
@ -83,5 +84,6 @@ func NewStubHelpers() *Helpers {
WindowArrangement: &WindowArrangementHelper{}, WindowArrangement: &WindowArrangementHelper{},
Search: &SearchHelper{}, Search: &SearchHelper{},
Worktree: &WorktreeHelper{}, Worktree: &WorktreeHelper{},
SubCommits: &SubCommitsHelper{},
} }
} }

View File

@ -334,11 +334,12 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit() error {
commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( commits, err := self.c.Git().Loaders.CommitLoader.GetCommits(
git_commands.GetCommitsOptions{ git_commands.GetCommitsOptions{
Limit: self.c.Contexts().SubCommits.GetLimitCommits(), Limit: self.c.Contexts().SubCommits.GetLimitCommits(),
FilterPath: self.c.Modes().Filtering.GetPath(), FilterPath: self.c.Modes().Filtering.GetPath(),
IncludeRebaseCommits: false, IncludeRebaseCommits: false,
RefName: self.c.Contexts().SubCommits.GetRef().FullRefName(), RefName: self.c.Contexts().SubCommits.GetRef().FullRefName(),
RefForPushedStatus: self.c.Contexts().SubCommits.GetRef().FullRefName(), RefToShowDivergenceFrom: self.c.Contexts().SubCommits.GetRefToShowDivergenceFrom(),
RefForPushedStatus: self.c.Contexts().SubCommits.GetRef().FullRefName(),
}, },
) )
if err != nil { if err != nil {

View File

@ -0,0 +1,73 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type SubCommitsHelper struct {
c *HelperCommon
refreshHelper *RefreshHelper
setSubCommits func([]*models.Commit)
}
func NewSubCommitsHelper(
c *HelperCommon,
refreshHelper *RefreshHelper,
setSubCommits func([]*models.Commit),
) *SubCommitsHelper {
return &SubCommitsHelper{
c: c,
refreshHelper: refreshHelper,
setSubCommits: setSubCommits,
}
}
type ViewSubCommitsOpts struct {
Ref types.Ref
RefToShowDivergenceFrom string
TitleRef string
Context types.Context
ShowBranchHeads bool
}
func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error {
commits, err := self.c.Git().Loaders.CommitLoader.GetCommits(
git_commands.GetCommitsOptions{
Limit: true,
FilterPath: self.c.Modes().Filtering.GetPath(),
IncludeRebaseCommits: false,
RefName: opts.Ref.FullRefName(),
RefForPushedStatus: opts.Ref.FullRefName(),
RefToShowDivergenceFrom: opts.RefToShowDivergenceFrom,
},
)
if err != nil {
return err
}
self.setSubCommits(commits)
self.refreshHelper.RefreshAuthors(commits)
subCommitsContext := self.c.Contexts().SubCommits
subCommitsContext.SetSelectedLineIdx(0)
subCommitsContext.SetParentContext(opts.Context)
subCommitsContext.SetWindowName(opts.Context.GetWindowName())
subCommitsContext.SetTitleRef(utils.TruncateWithEllipsis(opts.TitleRef, 50))
subCommitsContext.SetRef(opts.Ref)
subCommitsContext.SetRefToShowDivergenceFrom(opts.RefToShowDivergenceFrom)
subCommitsContext.SetLimitCommits(true)
subCommitsContext.SetShowBranchHeads(opts.ShowBranchHeads)
subCommitsContext.ClearSearchString()
subCommitsContext.GetView().ClearSearch()
err = self.c.PostRefreshUpdate(self.c.Contexts().SubCommits)
if err != nil {
return err
}
return self.c.PushContext(self.c.Contexts().SubCommits)
}

View File

@ -1,8 +1,7 @@
package controllers package controllers
import ( import (
"github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
) )
@ -18,20 +17,16 @@ type SwitchToSubCommitsController struct {
baseController baseController
c *ControllerCommon c *ControllerCommon
context CanSwitchToSubCommits context CanSwitchToSubCommits
setSubCommits func([]*models.Commit)
} }
func NewSwitchToSubCommitsController( func NewSwitchToSubCommitsController(
controllerCommon *ControllerCommon, controllerCommon *ControllerCommon,
setSubCommits func([]*models.Commit),
context CanSwitchToSubCommits, context CanSwitchToSubCommits,
) *SwitchToSubCommitsController { ) *SwitchToSubCommitsController {
return &SwitchToSubCommitsController{ return &SwitchToSubCommitsController{
baseController: baseController{}, baseController: baseController{},
c: controllerCommon, c: controllerCommon,
context: context, context: context,
setSubCommits: setSubCommits,
} }
} }
@ -57,40 +52,12 @@ func (self *SwitchToSubCommitsController) viewCommits() error {
return nil return nil
} }
// need to populate my sub commits return self.c.Helpers().SubCommits.ViewSubCommits(helpers.ViewSubCommitsOpts{
commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( Ref: ref,
git_commands.GetCommitsOptions{ TitleRef: ref.RefName(),
Limit: true, Context: self.context,
FilterPath: self.c.Modes().Filtering.GetPath(), ShowBranchHeads: self.context.ShowBranchHeadsInSubCommits(),
IncludeRebaseCommits: false, })
RefName: ref.FullRefName(),
RefForPushedStatus: ref.FullRefName(),
},
)
if err != nil {
return err
}
self.setSubCommits(commits)
self.c.Helpers().Refresh.RefreshAuthors(commits)
subCommitsContext := self.c.Contexts().SubCommits
subCommitsContext.SetSelectedLineIdx(0)
subCommitsContext.SetParentContext(self.context)
subCommitsContext.SetWindowName(self.context.GetWindowName())
subCommitsContext.SetTitleRef(ref.Description())
subCommitsContext.SetRef(ref)
subCommitsContext.SetLimitCommits(true)
subCommitsContext.SetShowBranchHeads(self.context.ShowBranchHeadsInSubCommits())
subCommitsContext.ClearSearchString()
subCommitsContext.GetView().ClearSearch()
err = self.c.PostRefreshUpdate(self.c.Contexts().SubCommits)
if err != nil {
return err
}
return self.c.PushContext(self.c.Contexts().SubCommits)
} }
func (self *SwitchToSubCommitsController) Context() types.Context { func (self *SwitchToSubCommitsController) Context() types.Context {

View File

@ -359,7 +359,9 @@ func displayCommit(
} }
cols := make([]string, 0, 7) cols := make([]string, 0, 7)
if icons.IsIconEnabled() { if commit.Divergence != models.DivergenceNone {
cols = append(cols, shaColor.Sprint(lo.Ternary(commit.Divergence == models.DivergenceLeft, "↑", "↓")))
} else if icons.IsIconEnabled() {
cols = append(cols, shaColor.Sprint(icons.IconForCommit(commit))) cols = append(cols, shaColor.Sprint(icons.IconForCommit(commit)))
} }
cols = append(cols, shaColor.Sprint(commit.ShortSha())) cols = append(cols, shaColor.Sprint(commit.ShortSha()))
@ -430,6 +432,8 @@ func getShaColor(
shaColor = theme.DiffTerminalColor shaColor = theme.DiffTerminalColor
} else if cherryPickedCommitShaSet.Includes(commit.Sha) { } else if cherryPickedCommitShaSet.Includes(commit.Sha) {
shaColor = theme.CherryPickedCommitTextStyle shaColor = theme.CherryPickedCommitTextStyle
} else if commit.Divergence == models.DivergenceRight && commit.Status != models.StatusMerged {
shaColor = style.FgBlue
} }
return shaColor return shaColor

View File

@ -348,6 +348,10 @@ type TranslationSet struct {
SetAsUpstream string SetAsUpstream string
SetUpstream string SetUpstream string
UnsetUpstream string UnsetUpstream string
ViewDivergenceFromUpstream string
DivergenceNoUpstream string
DivergenceSectionHeaderLocal string
DivergenceSectionHeaderRemote string
SetUpstreamTitle string SetUpstreamTitle string
SetUpstreamMessage string SetUpstreamMessage string
EditRemote string EditRemote string
@ -1128,6 +1132,10 @@ func EnglishTranslationSet() TranslationSet {
SetAsUpstream: "Set as upstream of checked-out branch", SetAsUpstream: "Set as upstream of checked-out branch",
SetUpstream: "Set upstream of selected branch", SetUpstream: "Set upstream of selected branch",
UnsetUpstream: "Unset upstream of selected branch", UnsetUpstream: "Unset upstream of selected branch",
ViewDivergenceFromUpstream: "View divergence from upstream",
DivergenceNoUpstream: "Cannot show divergence of a branch that has no (locally tracked) upstream",
DivergenceSectionHeaderLocal: "Local",
DivergenceSectionHeaderRemote: "Remote",
SetUpstreamTitle: "Set upstream branch", SetUpstreamTitle: "Set upstream branch",
SetUpstreamMessage: "Are you sure you want to set the upstream branch of '{{.checkedOut}}' to '{{.selected}}'", SetUpstreamMessage: "Are you sure you want to set the upstream branch of '{{.checkedOut}}' to '{{.selected}}'",
EditRemote: "Edit remote", EditRemote: "Edit remote",

View File

@ -39,6 +39,18 @@ func (self *TextMatcher) DoesNotContain(target string) *TextMatcher {
return self return self
} }
func (self *TextMatcher) DoesNotContainAnyOf(targets []string) *TextMatcher {
self.appendRule(matcherRule[string]{
name: fmt.Sprintf("does not contain any of '%s'", targets),
testFn: func(value string) (bool, string) {
return lo.NoneBy(targets, func(target string) bool { return strings.Contains(value, target) }),
fmt.Sprintf("Expected none of '%s' to be found in '%s'", targets, value)
},
})
return self
}
func (self *TextMatcher) MatchesRegexp(target string) *TextMatcher { func (self *TextMatcher) MatchesRegexp(target string) *TextMatcher {
self.appendRule(matcherRule[string]{ self.appendRule(matcherRule[string]{
name: fmt.Sprintf("matches regular expression '%s'", target), name: fmt.Sprintf("matches regular expression '%s'", target),
@ -107,6 +119,10 @@ func DoesNotContain(target string) *TextMatcher {
return AnyString().DoesNotContain(target) return AnyString().DoesNotContain(target)
} }
func DoesNotContainAnyOf(targets ...string) *TextMatcher {
return AnyString().DoesNotContainAnyOf(targets)
}
func MatchesRegexp(target string) *TextMatcher { func MatchesRegexp(target string) *TextMatcher {
return AnyString().MatchesRegexp(target) return AnyString().MatchesRegexp(target)
} }

View File

@ -0,0 +1,54 @@
package branch
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ShowDivergenceFromUpstream = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Show divergence from upstream",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file", "content1")
shell.Commit("one")
shell.UpdateFileAndAdd("file", "content2")
shell.Commit("two")
shell.CreateFileAndAdd("file3", "content3")
shell.Commit("three")
shell.CloneIntoRemote("origin")
shell.SetBranchUpstream("master", "origin/master")
shell.HardReset("HEAD^^")
shell.CreateFileAndAdd("file4", "content4")
shell.Commit("four")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Lines(
Contains("four"),
Contains("one"),
)
t.Views().Branches().
Focus().
Lines(Contains("master")).
Press(keys.Branches.SetUpstream)
t.ExpectPopup().Menu().Title(Contains("upstream")).Select(Contains("View divergence from upstream")).Confirm()
t.Views().SubCommits().
IsFocused().
Title(Contains("Commits (master <-> origin/master)")).
Lines(
DoesNotContainAnyOf("↓", "↑").Contains("--- Remote ---"),
Contains("↓").Contains("three"),
Contains("↓").Contains("two"),
DoesNotContainAnyOf("↓", "↑").Contains("--- Local ---"),
Contains("↑").Contains("four"),
)
},
})

View File

@ -49,6 +49,7 @@ var tests = []*components.IntegrationTest{
branch.Reset, branch.Reset,
branch.ResetUpstream, branch.ResetUpstream,
branch.SetUpstream, branch.SetUpstream,
branch.ShowDivergenceFromUpstream,
branch.Suggestions, branch.Suggestions,
cherry_pick.CherryPick, cherry_pick.CherryPick,
cherry_pick.CherryPickConflicts, cherry_pick.CherryPickConflicts,