diff --git a/docs/Config.md b/docs/Config.md index e3890f9f6..f752e026b 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -88,6 +88,9 @@ git: # displays the whole git graph by default in the commits panel (equivalent to passing the `--all` argument to `git log`) showWholeGraph: false skipHookPrefix: WIP + # The main branches. We colour commits green if they belong to one of these branches, + # so that you can easily see which commits are unique to your branch (coloured in yellow) + mainBranches: [master, main] autoFetch: true autoRefresh: true branchLogCmd: 'git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --' diff --git a/pkg/commands/git.go b/pkg/commands/git.go index caf03db75..4facd4f24 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -129,7 +129,7 @@ func NewGitCommandAux( branchLoader := git_commands.NewBranchLoader(cmn, branchCommands.GetRawBranches, branchCommands.CurrentBranchInfo, configCommands) commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd) - commitLoader := git_commands.NewCommitLoader(cmn, cmd, dotGitDir, branchCommands.CurrentBranchInfo, statusCommands.RebaseMode) + commitLoader := git_commands.NewCommitLoader(cmn, cmd, dotGitDir, statusCommands.RebaseMode) reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd) remoteLoader := git_commands.NewRemoteLoader(cmn, cmd, repo.Remotes) stashLoader := git_commands.NewStashLoader(cmn, cmd) diff --git a/pkg/commands/git_commands/commit_loader.go b/pkg/commands/git_commands/commit_loader.go index 0ab3ceee4..e68f56ebb 100644 --- a/pkg/commands/git_commands/commit_loader.go +++ b/pkg/commands/git_commands/commit_loader.go @@ -15,6 +15,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/commands/types/enums" "github.com/jesseduffield/lazygit/pkg/common" + "github.com/samber/lo" ) // context: @@ -28,11 +29,14 @@ type CommitLoader struct { *common.Common cmd oscommands.ICmdObjBuilder - getCurrentBranchInfo func() (BranchInfo, error) - getRebaseMode func() (enums.RebaseMode, error) - readFile func(filename string) ([]byte, error) - walkFiles func(root string, fn filepath.WalkFunc) error - dotGitDir string + getRebaseMode func() (enums.RebaseMode, error) + readFile func(filename string) ([]byte, error) + walkFiles func(root string, fn filepath.WalkFunc) error + dotGitDir string + // List of main branches that exist in the repo, quoted for direct use in a git command. + // We use these to obtain the merge base of the branch. + // When nil, we're yet to obtain the list of main branches. + quotedMainBranches *string } // making our dependencies explicit for the sake of easier testing @@ -40,17 +44,16 @@ func NewCommitLoader( cmn *common.Common, cmd oscommands.ICmdObjBuilder, dotGitDir string, - getCurrentBranchInfo func() (BranchInfo, error), getRebaseMode func() (enums.RebaseMode, error), ) *CommitLoader { return &CommitLoader{ - Common: cmn, - cmd: cmd, - getCurrentBranchInfo: getCurrentBranchInfo, - getRebaseMode: getRebaseMode, - readFile: os.ReadFile, - walkFiles: filepath.Walk, - dotGitDir: dotGitDir, + Common: cmn, + cmd: cmd, + getRebaseMode: getRebaseMode, + readFile: os.ReadFile, + walkFiles: filepath.Walk, + dotGitDir: dotGitDir, + quotedMainBranches: nil, } } @@ -101,10 +104,7 @@ func (self *CommitLoader) GetCommits(opts GetCommitsOptions) ([]*models.Commit, return commits, nil } - commits, err = self.setCommitMergedStatuses(opts.RefName, commits) - if err != nil { - return nil, err - } + commits = self.setCommitMergedStatuses(opts.RefName, commits) return commits, nil } @@ -344,13 +344,10 @@ func (self *CommitLoader) commitFromPatch(content string) *models.Commit { } } -func (self *CommitLoader) setCommitMergedStatuses(refName string, commits []*models.Commit) ([]*models.Commit, error) { - ancestor, err := self.getMergeBase(refName) - if err != nil { - return nil, err - } +func (self *CommitLoader) setCommitMergedStatuses(refName string, commits []*models.Commit) []*models.Commit { + ancestor := self.getMergeBase(refName) if ancestor == "" { - return commits, nil + return commits } passedAncestor := false for i, commit := range commits { @@ -364,23 +361,41 @@ func (self *CommitLoader) setCommitMergedStatuses(refName string, commits []*mod commits[i].Status = models.StatusMerged } } - return commits, nil + return commits } -func (self *CommitLoader) getMergeBase(refName string) (string, error) { - info, err := self.getCurrentBranchInfo() +func (self *CommitLoader) getMergeBase(refName string) string { + if self.quotedMainBranches == nil { + self.quotedMainBranches = lo.ToPtr(self.getExistingMainBranches()) + } + + if *self.quotedMainBranches == "" { + return "" + } + + // We pass all configured main branches to the merge-base call; git will + // return the base commit for the closest one. + output, err := self.cmd.New(fmt.Sprintf("git merge-base %s %s", + self.cmd.Quote(refName), *self.quotedMainBranches)).DontLog().RunWithOutput() if err != nil { - return "", err + // If there's an error, it must be because one of the main branches that + // used to exist when we called getExistingMainBranches() was deleted + // meanwhile. To fix this for next time, throw away our cache. + self.quotedMainBranches = nil } + return ignoringWarnings(output) +} - baseBranch := "master" - if strings.HasPrefix(info.RefName, "feature/") { - baseBranch = "develop" - } - - // swallowing error because it's not a big deal; probably because there are no commits yet - output, _ := self.cmd.New(fmt.Sprintf("git merge-base %s %s", self.cmd.Quote(refName), self.cmd.Quote(baseBranch))).DontLog().RunWithOutput() - return ignoringWarnings(output), nil +func (self *CommitLoader) getExistingMainBranches() string { + return strings.Join( + lo.FilterMap(self.UserConfig.Git.MainBranches, + func(branchName string, _ int) (string, bool) { + quotedRef := self.cmd.Quote("refs/heads/" + branchName) + if err := self.cmd.New(fmt.Sprintf("git rev-parse --verify --quiet %s", quotedRef)).DontLog().Run(); err != nil { + return "", false + } + return quotedRef, true + }), " ") } func ignoringWarnings(commandOutput string) string { diff --git a/pkg/commands/git_commands/commit_loader_test.go b/pkg/commands/git_commands/commit_loader_test.go index 140541545..0b2df681c 100644 --- a/pkg/commands/git_commands/commit_loader_test.go +++ b/pkg/commands/git_commands/commit_loader_test.go @@ -1,6 +1,7 @@ package git_commands import ( + "errors" "path/filepath" "strings" "testing" @@ -21,25 +22,26 @@ d8084cd558925eb7c9c38afeed5725c21653ab90|1640821426|Jesse Duffield|jessedduffiel 3d4470a6c072208722e5ae9a54bcb9634959a1c5|1640748818|Jesse Duffield|jessedduffield@gmail.com||053a66a7be3da43aacdc|WIP 053a66a7be3da43aacdc7aa78e1fe757b82c4dd2|1640739815|Jesse Duffield|jessedduffield@gmail.com||985fe482e806b172aea4|refactoring the config struct`, "|", "\x00", -1) +var singleCommitOutput = strings.Replace(`0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com| (HEAD -> better-tests)|b21997d6b4cbdf84b149|better typing for rebase mode`, "|", "\x00", -1) + func TestGetCommits(t *testing.T) { type scenario struct { - testName string - runner *oscommands.FakeCmdObjRunner - expectedCommits []*models.Commit - expectedError error - logOrder string - rebaseMode enums.RebaseMode - currentBranchName string - opts GetCommitsOptions + testName string + runner *oscommands.FakeCmdObjRunner + expectedCommits []*models.Commit + expectedError error + logOrder string + rebaseMode enums.RebaseMode + opts GetCommitsOptions + mainBranches []string } scenarios := []scenario{ { - testName: "should return no commits if there are none", - logOrder: "topo-order", - rebaseMode: enums.REBASE_MODE_NONE, - currentBranchName: "master", - opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false}, + testName: "should return no commits if there are none", + logOrder: "topo-order", + rebaseMode: enums.REBASE_MODE_NONE, + opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false}, runner: oscommands.NewFakeRunner(t). Expect(`git merge-base "HEAD" "HEAD"@{u}`, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). Expect(`git 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), @@ -48,11 +50,10 @@ func TestGetCommits(t *testing.T) { expectedError: nil, }, { - testName: "should use proper upstream name for branch", - logOrder: "topo-order", - rebaseMode: enums.REBASE_MODE_NONE, - currentBranchName: "mybranch", - opts: GetCommitsOptions{RefName: "refs/heads/mybranch", IncludeRebaseCommits: false}, + testName: "should use proper upstream name for branch", + logOrder: "topo-order", + rebaseMode: enums.REBASE_MODE_NONE, + opts: GetCommitsOptions{RefName: "refs/heads/mybranch", IncludeRebaseCommits: false}, runner: oscommands.NewFakeRunner(t). Expect(`git merge-base "refs/heads/mybranch" "mybranch"@{u}`, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). Expect(`git 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), @@ -61,18 +62,21 @@ func TestGetCommits(t *testing.T) { expectedError: nil, }, { - testName: "should return commits if they are present", - logOrder: "topo-order", - rebaseMode: enums.REBASE_MODE_NONE, - currentBranchName: "master", - opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false}, + testName: "should return commits if they are present", + logOrder: "topo-order", + rebaseMode: enums.REBASE_MODE_NONE, + opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false}, + mainBranches: []string{"master", "main"}, runner: oscommands.NewFakeRunner(t). // here it's seeing which commits are yet to be pushed Expect(`git merge-base "HEAD" "HEAD"@{u}`, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). // here it's actually getting all the commits in a formatted form, one per line Expect(`git 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). + // here it's testing which of the configured main branches exist + Expect(`git rev-parse --verify --quiet "refs/heads/master"`, "", nil). // this one does + Expect(`git rev-parse --verify --quiet "refs/heads/main"`, "", errors.New("error")). // this one doesn't // here it's seeing where our branch diverged from the master branch so that we can mark that commit and parent commits as 'merged' - Expect(`git merge-base "HEAD" "master"`, "26c07b1ab33860a1a7591a0638f9925ccf497ffa", nil), + Expect(`git merge-base "HEAD" "refs/heads/master"`, "26c07b1ab33860a1a7591a0638f9925ccf497ffa", nil), expectedCommits: []*models.Commit{ { @@ -191,11 +195,80 @@ func TestGetCommits(t *testing.T) { expectedError: nil, }, { - testName: "should not specify order if `log.order` is `default`", - logOrder: "default", - rebaseMode: enums.REBASE_MODE_NONE, - currentBranchName: "master", - opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false}, + testName: "should not call merge-base for mainBranches if none exist", + logOrder: "topo-order", + rebaseMode: enums.REBASE_MODE_NONE, + opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false}, + mainBranches: []string{"master", "main"}, + runner: oscommands.NewFakeRunner(t). + // here it's seeing which commits are yet to be pushed + Expect(`git merge-base "HEAD" "HEAD"@{u}`, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). + // here it's actually getting all the commits in a formatted form, one per line + Expect(`git 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). + // here it's testing which of the configured main branches exist; neither does + Expect(`git rev-parse --verify --quiet "refs/heads/master"`, "", errors.New("error")). + Expect(`git rev-parse --verify --quiet "refs/heads/main"`, "", errors.New("error")), + + expectedCommits: []*models.Commit{ + { + Sha: "0eea75e8c631fba6b58135697835d58ba4c18dbc", + Name: "better typing for rebase mode", + Status: models.StatusUnpushed, + Action: models.ActionNone, + Tags: []string{}, + ExtraInfo: "(HEAD -> better-tests)", + AuthorName: "Jesse Duffield", + AuthorEmail: "jessedduffield@gmail.com", + UnixTimestamp: 1640826609, + Parents: []string{ + "b21997d6b4cbdf84b149", + }, + }, + }, + expectedError: nil, + }, + { + testName: "should call merge-base for all main branches that exist", + logOrder: "topo-order", + rebaseMode: enums.REBASE_MODE_NONE, + opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false}, + mainBranches: []string{"master", "main", "develop", "1.0-hotfixes"}, + runner: oscommands.NewFakeRunner(t). + // here it's seeing which commits are yet to be pushed + Expect(`git merge-base "HEAD" "HEAD"@{u}`, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). + // here it's actually getting all the commits in a formatted form, one per line + Expect(`git 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). + // here it's testing which of the configured main branches exist + Expect(`git rev-parse --verify --quiet "refs/heads/master"`, "", nil). + Expect(`git rev-parse --verify --quiet "refs/heads/main"`, "", errors.New("error")). + Expect(`git rev-parse --verify --quiet "refs/heads/develop"`, "", nil). + Expect(`git rev-parse --verify --quiet "refs/heads/1.0-hotfixes"`, "", nil). + // here it's seeing where our branch diverged from the master branch so that we can mark that commit and parent commits as 'merged' + Expect(`git merge-base "HEAD" "refs/heads/master" "refs/heads/develop" "refs/heads/1.0-hotfixes"`, "26c07b1ab33860a1a7591a0638f9925ccf497ffa", nil), + + expectedCommits: []*models.Commit{ + { + Sha: "0eea75e8c631fba6b58135697835d58ba4c18dbc", + Name: "better typing for rebase mode", + Status: models.StatusUnpushed, + Action: models.ActionNone, + Tags: []string{}, + ExtraInfo: "(HEAD -> better-tests)", + AuthorName: "Jesse Duffield", + AuthorEmail: "jessedduffield@gmail.com", + UnixTimestamp: 1640826609, + Parents: []string{ + "b21997d6b4cbdf84b149", + }, + }, + }, + expectedError: nil, + }, + { + testName: "should not specify order if `log.order` is `default`", + logOrder: "default", + rebaseMode: enums.REBASE_MODE_NONE, + opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false}, runner: oscommands.NewFakeRunner(t). Expect(`git merge-base "HEAD" "HEAD"@{u}`, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). Expect(`git log "HEAD" --oneline --pretty=format:"%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s" --abbrev=40 --no-show-signature --`, "", nil), @@ -204,11 +277,10 @@ func TestGetCommits(t *testing.T) { expectedError: nil, }, { - testName: "should set filter path", - logOrder: "default", - rebaseMode: enums.REBASE_MODE_NONE, - currentBranchName: "master", - opts: GetCommitsOptions{RefName: "HEAD", FilterPath: "src"}, + testName: "should set filter path", + logOrder: "default", + rebaseMode: enums.REBASE_MODE_NONE, + opts: GetCommitsOptions{RefName: "HEAD", FilterPath: "src"}, runner: oscommands.NewFakeRunner(t). Expect(`git merge-base "HEAD" "HEAD"@{u}`, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil). Expect(`git 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), @@ -225,11 +297,8 @@ func TestGetCommits(t *testing.T) { common.UserConfig.Git.Log.Order = scenario.logOrder builder := &CommitLoader{ - Common: common, - cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner), - getCurrentBranchInfo: func() (BranchInfo, error) { - return BranchInfo{RefName: scenario.currentBranchName, DisplayName: scenario.currentBranchName, DetachedHead: false}, nil - }, + Common: common, + cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner), getRebaseMode: func() (enums.RebaseMode, error) { return scenario.rebaseMode, nil }, dotGitDir: ".git", readFile: func(filename string) ([]byte, error) { @@ -240,6 +309,7 @@ func TestGetCommits(t *testing.T) { }, } + common.UserConfig.Git.MainBranches = scenario.mainBranches commits, err := builder.GetCommits(scenario.opts) assert.Equal(t, scenario.expectedCommits, commits) diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 82361ddbd..9212a2de8 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -76,6 +76,7 @@ type GitConfig struct { Paging PagingConfig `yaml:"paging"` Commit CommitConfig `yaml:"commit"` Merging MergingConfig `yaml:"merging"` + MainBranches []string `yaml:"mainBranches"` SkipHookPrefix string `yaml:"skipHookPrefix"` AutoFetch bool `yaml:"autoFetch"` AutoRefresh bool `yaml:"autoRefresh"` @@ -443,6 +444,7 @@ func GetDefaultConfig() *UserConfig { ShowWholeGraph: false, }, SkipHookPrefix: "WIP", + MainBranches: []string{"master", "main"}, AutoFetch: true, AutoRefresh: true, BranchLogCmd: "git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --",