Compare commits

...

4 Commits

Author SHA1 Message Date
Jesse Duffield
017367f940 Show commit merged/push statuses against branches
When viewing branches in an enlargened window, we now colour the commit shas based on
whether the branch has been merged to a main branch and whether it's been pushed to
the upstream.

This isn't using the exact same code as we use for commits, so it's possible we'll get
a colour mismatch when the user checks out a branch and views the commits in the commits
view, but if we come across that situation we'll just need to fix it.
2023-10-11 15:51:58 +11:00
Jesse Duffield
16369dc3c2 Introduce ConcurrentMap util 2023-10-11 15:50:17 +11:00
Jesse Duffield
86bb73cff1 Better Explain the red/yellow/green commit colouring 2023-10-11 15:50:17 +11:00
Jesse Duffield
bac12231c3 Don't bother checking for origin/main when loading commits
The real reason I'm doing this is to that it's easier to explain the logic behind setting
commit merged statuses. Now it's just 'try the upstream, fallback to the local branch'.

Other justifications:
* we should avoid assuming that 'origin' is the main upstream remote
* it's one less git call
* surely the main branch _will_ have an upsream. I doubt many people have benefitted
  from the extra fallback to origin
2023-10-11 15:50:17 +11:00
13 changed files with 206 additions and 94 deletions

View File

@@ -478,9 +478,13 @@ If you would like to support the development of lazygit, consider [sponsoring me
### What do the commit colors represent?
- Green: the commit is included in the master branch
- Yellow: the commit is not included in the master branch
- Red: the commit has not been pushed to the upstream branch
- Green: the commit is included in the main branch's upstream
- Yellow: the commit is not included in the main branch's upstream
- Red: the commit has not been pushed to the given branch's upstream
By 'main' branch we mean any branch defined in the `git.mainBranches` config which defaults to 'master' and 'main'. We check against the upstream and if that's missing we fall back to checking against the local branch itself. We prioritise the upstream because it's typically more up-to-date.
This colouring scheme lets you see at a glance which commits belong to your branch, and which commits belong to the main branch.
## Shameless Plug

View File

@@ -47,6 +47,7 @@ type GitCommand struct {
type Loaders struct {
BranchLoader *git_commands.BranchLoader
MergedBranchLoader *git_commands.MergedBranchLoader
CommitFileLoader *git_commands.CommitFileLoader
CommitLoader *git_commands.CommitLoader
FileLoader *git_commands.FileLoader
@@ -162,6 +163,7 @@ func NewGitCommandAux(
worktreeCommands := git_commands.NewWorktreeCommands(gitCommon)
branchLoader := git_commands.NewBranchLoader(cmn, cmd, branchCommands.CurrentBranchInfo, configCommands)
mergedBranchLoader := git_commands.NewMergedBranchLoader(gitCommon)
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
commitLoader := git_commands.NewCommitLoader(cmn, cmd, statusCommands.RebaseMode, gitCommon)
reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd)
@@ -200,6 +202,7 @@ func NewGitCommandAux(
Worktrees: worktreeLoader,
StashLoader: stashLoader,
TagLoader: tagLoader,
MergedBranchLoader: mergedBranchLoader,
},
RepoPaths: repoPaths,
}

View File

@@ -568,50 +568,26 @@ func (self *CommitLoader) getMergeBase(refName string) string {
}
func (self *CommitLoader) getExistingMainBranches() []string {
var existingBranches []string
var wg sync.WaitGroup
mainBranches := self.UserConfig.Git.MainBranches
existingBranches = make([]string, len(mainBranches))
for i, branchName := range mainBranches {
wg.Add(1)
i := i
branchName := branchName
go utils.Safe(func() {
defer wg.Done()
existingBranches := utils.ConcurrentMap(mainBranches, func(mainBranch string) string {
// Try to determine upstream of local main branch
if ref, err := self.cmd.New(
NewGitCmd("rev-parse").Arg("--symbolic-full-name", mainBranch+"@{u}").ToArgv(),
).DontLog().RunWithOutput(); err == nil {
return strings.TrimSpace(ref)
}
// Try to determine upstream of local main branch
if ref, err := self.cmd.New(
NewGitCmd("rev-parse").Arg("--symbolic-full-name", branchName+"@{u}").ToArgv(),
).DontLog().RunWithOutput(); err == nil {
existingBranches[i] = strings.TrimSpace(ref)
return
}
// If this failed, fallback to the local branch
ref := "refs/heads/" + mainBranch
if err := self.cmd.New(
NewGitCmd("rev-parse").Arg("--verify", "--quiet", ref).ToArgv(),
).DontLog().Run(); err == nil {
return ref
}
// If this failed, a local branch for this main branch doesn't exist or it
// has no upstream configured. Try looking for one in the "origin" remote.
ref := "refs/remotes/origin/" + branchName
if err := self.cmd.New(
NewGitCmd("rev-parse").Arg("--verify", "--quiet", ref).ToArgv(),
).DontLog().Run(); err == nil {
existingBranches[i] = ref
return
}
// If this failed as well, try if we have the main branch as a local
// branch. This covers the case where somebody is using git locally
// for something, but never pushing anywhere.
ref = "refs/heads/" + branchName
if err := self.cmd.New(
NewGitCmd("rev-parse").Arg("--verify", "--quiet", ref).ToArgv(),
).DontLog().Run(); err == nil {
existingBranches[i] = ref
}
})
}
wg.Wait()
return ""
})
existingBranches = lo.Filter(existingBranches, func(branch string, _ int) bool {
return branch != ""

View File

@@ -74,12 +74,10 @@ func TestGetCommits(t *testing.T) {
// 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%x00%m", "--abbrev=40", "--no-show-signature", "--"}, commitsOutput, nil).
// 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", "main@{u}"}, "", errors.New("error")). // this one doesn't, so it checks origin instead
ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"}, "", nil). // yep, origin/main exists
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "develop@{u}"}, "", errors.New("error")). // this one doesn't, so it checks origin instead
ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/remotes/origin/develop"}, "", errors.New("error")). // doesn't exist there, either, so it checks for a local branch
ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/heads/develop"}, "", errors.New("error")). // no local branch either
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}"}, "refs/remotes/origin/main", nil). // this one does too
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "develop@{u}"}, "", errors.New("error")). // this one doesn't, so check for a local branch
ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/heads/develop"}, "", errors.New("error")). // no local branch either
// here it's seeing where our branch diverged from the master branch so that we can mark that commit and parent commits as 'merged'
ExpectGitArgs([]string{"merge-base", "HEAD", "refs/remotes/origin/master", "refs/remotes/origin/main"}, "26c07b1ab33860a1a7591a0638f9925ccf497ffa", nil),
@@ -212,10 +210,8 @@ func TestGetCommits(t *testing.T) {
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
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/heads/master"}, "", errors.New("error")).
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "main@{u}"}, "", errors.New("error")).
ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"}, "", errors.New("error")).
ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/heads/main"}, "", errors.New("error")),
expectedCommits: []*models.Commit{
@@ -250,7 +246,6 @@ func TestGetCommits(t *testing.T) {
// 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", "main@{u}"}, "", errors.New("error")).
ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"}, "", errors.New("error")).
ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/heads/main"}, "", errors.New("error")).
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "develop@{u}"}, "refs/remotes/origin/develop", nil).
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "1.0-hotfixes@{u}"}, "refs/remotes/origin/1.0-hotfixes", nil).

View File

@@ -0,0 +1,59 @@
package git_commands
import (
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/utils"
)
// Returns a set of branch names which are merged into a main branch.
// This
type MergedBranchLoader struct {
c *GitCommon
}
func NewMergedBranchLoader(c *GitCommon) *MergedBranchLoader {
return &MergedBranchLoader{c: c}
}
// TODO: check against upstreams, and share code with commit loader that determines main branches
func (self *MergedBranchLoader) Load() *set.Set[string] {
set := set.New[string]()
mainBranches := self.c.UserConfig.Git.MainBranches
results := utils.ConcurrentMap(mainBranches, func(mainBranch string) []string {
mergedBranches, err := self.GetMergedBranches(mainBranch)
if err != nil {
self.c.Log.Warnf("Failed to get merged branches for %s: %s", mainBranch, err)
return nil
}
return mergedBranches
})
for i := 0; i < len(mainBranches); i++ {
for _, mergedBranch := range results[i] {
set.Add(mergedBranch)
}
}
return set
}
func (self *MergedBranchLoader) GetMergedBranches(mainBranch string) ([]string, error) {
// git for-each-ref --merged master --format '%(refname)' refs/heads/
cmdArgs := NewGitCmd("for-each-ref").Arg(
"--merged",
mainBranch,
"--format",
"%(refname)",
"refs/heads/",
).ToArgv()
output, err := self.c.cmd.New(cmdArgs).DontLog().RunWithOutput()
if err != nil {
return nil, err
}
branches := utils.SplitLines(output)
return branches, nil
}

View File

@@ -33,6 +33,7 @@ func NewBranchesContext(c *ContextCommon) *BranchesContext {
c.Tr,
c.UserConfig,
c.Model().Worktrees,
c.Model().MergedBranches,
)
}

View File

@@ -429,25 +429,43 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool) {
self.c.Mutexes().RefreshingBranchesMutex.Lock()
defer self.c.Mutexes().RefreshingBranchesMutex.Unlock()
reflogCommits := self.c.Model().FilteredReflogCommits
if self.c.Modes().Filtering.Active() {
// in filter mode we filter our reflog commits to just those containing the path
// however we need all the reflog entries to populate the recencies of our branches
// which allows us to order them correctly. So if we're filtering we'll just
// manually load all the reflog commits here
var err error
reflogCommits, _, err = self.c.Git().Loaders.ReflogCommitLoader.GetReflogCommits(nil, "")
if err != nil {
self.c.Log.Error(err)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
reflogCommits := self.c.Model().FilteredReflogCommits
if self.c.Modes().Filtering.Active() {
// in filter mode we filter our reflog commits to just those containing the path
// however we need all the reflog entries to populate the recencies of our branches
// which allows us to order them correctly. So if we're filtering we'll just
// manually load all the reflog commits here
var err error
reflogCommits, _, err = self.c.Git().Loaders.ReflogCommitLoader.GetReflogCommits(nil, "")
if err != nil {
self.c.Log.Error(err)
}
}
}
branches, err := self.c.Git().Loaders.BranchLoader.Load(reflogCommits)
if err != nil {
_ = self.c.Error(err)
}
branches, err := self.c.Git().Loaders.BranchLoader.Load(reflogCommits)
if err != nil {
_ = self.c.Error(err)
}
self.c.Model().Branches = branches
self.c.Model().Branches = branches
}()
go func() {
defer wg.Done()
mergedBranches := self.c.Git().Loaders.MergedBranchLoader.Load()
self.c.Model().MergedBranches = mergedBranches
}()
wg.Wait()
if refreshWorktrees {
self.loadWorktrees()

View File

@@ -8,6 +8,7 @@ import (
"strings"
"sync"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazycore/pkg/boxlayout"
appTypes "github.com/jesseduffield/lazygit/pkg/app/types"
@@ -384,6 +385,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
BisectInfo: git_commands.NewNullBisectInfo(),
FilesTrie: patricia.NewTrie(),
Authors: map[string]*models.Author{},
MergedBranches: set.New[string](),
},
Modes: &types.Modes{
Filtering: filtering.New(startArgs.FilterPath),

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"strings"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
@@ -26,10 +27,11 @@ func GetBranchListDisplayStrings(
tr *i18n.TranslationSet,
userConfig *config.UserConfig,
worktrees []*models.Worktree,
mergedBranches *set.Set[string],
) [][]string {
return lo.Map(branches, func(branch *models.Branch, _ int) []string {
diffed := branch.Name == diffName
return getBranchDisplayStrings(branch, getItemOperation(branch), fullDescription, diffed, tr, userConfig, worktrees)
return getBranchDisplayStrings(branch, getItemOperation(branch), fullDescription, diffed, tr, userConfig, worktrees, mergedBranches)
})
}
@@ -42,6 +44,7 @@ func getBranchDisplayStrings(
tr *i18n.TranslationSet,
userConfig *config.UserConfig,
worktrees []*models.Worktree,
mergedBranches *set.Set[string],
) []string {
displayName := b.Name
if b.DisplayName != "" {
@@ -74,7 +77,15 @@ func getBranchDisplayStrings(
}
if fullDescription || userConfig.Gui.ShowBranchCommitHash {
res = append(res, utils.ShortSha(b.CommitHash))
var commitStyle style.TextStyle
if b.HasCommitsToPush() {
commitStyle = getShaStyleFromStatus(models.StatusUnpushed)
} else if mergedBranches.Includes(b.FullRefName()) {
commitStyle = getShaStyleFromStatus(models.StatusMerged)
} else {
commitStyle = getShaStyleFromStatus(models.StatusPushed)
}
res = append(res, commitStyle.Sprint(utils.ShortSha(b.CommitHash)))
}
res = append(res, coloredName)

View File

@@ -413,20 +413,8 @@ func getShaColor(
}
diffed := commit.Sha != "" && commit.Sha == diffName
shaColor := theme.DefaultTextColor
switch commit.Status {
case models.StatusUnpushed:
shaColor = style.FgRed
case models.StatusPushed:
shaColor = style.FgYellow
case models.StatusMerged:
shaColor = style.FgGreen
case models.StatusRebasing:
shaColor = style.FgBlue
case models.StatusReflog:
shaColor = style.FgBlue
default:
}
shaColor := getShaStyleFromStatus(commit.Status)
if diffed {
shaColor = theme.DiffTerminalColor
@@ -439,6 +427,23 @@ func getShaColor(
return shaColor
}
func getShaStyleFromStatus(status models.CommitStatus) style.TextStyle {
switch status {
case models.StatusUnpushed:
return style.FgRed
case models.StatusPushed:
return style.FgYellow
case models.StatusMerged:
return style.FgGreen
case models.StatusRebasing:
return style.FgBlue
case models.StatusReflog:
return style.FgBlue
default:
return theme.DefaultTextColor
}
}
func actionColorMap(action todo.TodoCommand) style.TextStyle {
switch action {
case todo.Pick:

View File

@@ -1,6 +1,7 @@
package types
import (
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
@@ -223,15 +224,17 @@ type MenuItem struct {
}
type Model struct {
CommitFiles []*models.CommitFile
Files []*models.File
Submodules []*models.SubmoduleConfig
Branches []*models.Branch
Commits []*models.Commit
StashEntries []*models.StashEntry
SubCommits []*models.Commit
Remotes []*models.Remote
Worktrees []*models.Worktree
CommitFiles []*models.CommitFile
Files []*models.File
Submodules []*models.SubmoduleConfig
Branches []*models.Branch
// set of branch names of the form 'refs/heads/mybranch'
MergedBranches *set.Set[string]
Commits []*models.Commit
StashEntries []*models.StashEntry
SubCommits []*models.Commit
Remotes []*models.Remote
Worktrees []*models.Worktree
// FilteredReflogCommits are the ones that appear in the reflog panel.
// when in filtering mode we only include the ones that match the given path

View File

@@ -1,6 +1,10 @@
package utils
import "golang.org/x/exp/slices"
import (
"sync"
"golang.org/x/exp/slices"
)
// NextIndex returns the index of the element that comes after the given number
func NextIndex(numbers []int, currentNumber int) int {
@@ -179,3 +183,24 @@ func Shift[T any](slice []T) (T, []T) {
slice = slice[1:]
return value, slice
}
// Map function which handles each element in its own goroutine
func ConcurrentMap[T any, V any](items []T, fn func(T) V) []V {
results := make([]V, len(items))
var wg sync.WaitGroup
for i, item := range items {
i := i
item := item
wg.Add(1)
go func() {
defer wg.Done()
results[i] = fn(item)
}()
}
wg.Wait()
return results
}

View File

@@ -315,3 +315,13 @@ func TestMoveElement(t *testing.T) {
})
})
}
func TestConcurrentMap(t *testing.T) {
in := []int{1, 2, 3}
out := ConcurrentMap(in, func(i int) int {
return i * 2
})
assert.EqualValues(t, []int{2, 4, 6}, out)
}