Show divergence from base branch in branches list

This commit is contained in:
Stefan Haller 2024-04-30 12:34:05 +02:00
parent 5b613f5bc7
commit 373b1970ca
11 changed files with 281 additions and 51 deletions

View file

@ -187,6 +187,10 @@ gui:
# If true, show commit hashes alongside branch names in the branches view.
showBranchCommitHash: false
# Whether to show the divergence from the base branch in the branches view.
# One of: 'none' | 'onlyArrow' | 'arrowAndNumber'
showDivergenceFromBaseBranch: none
# Height of the command log view
commandLogSize: 8

View file

@ -5,6 +5,7 @@ import (
"regexp"
"strconv"
"strings"
"time"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/go-git/v5/config"
@ -14,6 +15,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"golang.org/x/exp/slices"
"golang.org/x/sync/errgroup"
)
// context:
@ -63,7 +65,13 @@ func NewBranchLoader(
}
// Load the list of branches for the current repo
func (self *BranchLoader) Load(reflogCommits []*models.Commit) ([]*models.Branch, error) {
func (self *BranchLoader) Load(reflogCommits []*models.Commit,
mainBranches *MainBranches,
oldBranches []*models.Branch,
loadBehindCounts bool,
onWorker func(func() error),
renderFunc func(),
) ([]*models.Branch, error) {
branches := self.obtainBranches(self.version.IsAtLeast(2, 22, 0))
if self.AppState.LocalBranchSortOrder == "recency" {
@ -122,11 +130,75 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit) ([]*models.Branch
branch.UpstreamRemote = match.Remote
branch.UpstreamBranch = match.Merge.Short()
}
// If the branch already existed, take over its BehindBaseBranch value
// to reduce flicker
if oldBranch, found := lo.Find(oldBranches, func(b *models.Branch) bool {
return b.Name == branch.Name
}); found {
branch.BehindBaseBranch.Store(oldBranch.BehindBaseBranch.Load())
}
}
if loadBehindCounts && self.UserConfig.Gui.ShowDivergenceFromBaseBranch != "none" {
onWorker(func() error {
return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc)
})
}
return branches, nil
}
func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches(
branches []*models.Branch,
mainBranches *MainBranches,
renderFunc func(),
) error {
mainBranchRefs := mainBranches.Get()
if len(mainBranchRefs) == 0 {
return nil
}
t := time.Now()
errg := errgroup.Group{}
for _, branch := range branches {
errg.Go(func() error {
baseBranch, err := self.GetBaseBranch(branch, mainBranches)
if err != nil {
return err
}
behind := 0 // prime it in case something below fails
if baseBranch != "" {
output, err := self.cmd.New(
NewGitCmd("rev-list").
Arg("--left-right").
Arg("--count").
Arg(fmt.Sprintf("%s...%s", branch.FullRefName(), baseBranch)).
ToArgv(),
).DontLog().RunWithOutput()
if err != nil {
return err
}
// The format of the output is "<ahead>\t<behind>"
aheadBehindStr := strings.Split(strings.TrimSpace(output), "\t")
if len(aheadBehindStr) == 2 {
if value, err := strconv.Atoi(aheadBehindStr[1]); err == nil {
behind = value
}
}
}
branch.BehindBaseBranch.Store(int32(behind))
return nil
})
}
err := errg.Wait()
self.Log.Debugf("time to get behind base branch values for all branches: %s", time.Since(t))
renderFunc()
return err
}
// Find the base branch for the given branch (i.e. the main branch that the
// given branch was forked off of)
//

View file

@ -1,6 +1,9 @@
package models
import "fmt"
import (
"fmt"
"sync/atomic"
)
// Branch : A git branch
// duplicating this for now
@ -32,6 +35,11 @@ type Branch struct {
Subject string
// commit hash
CommitHash string
// How far we have fallen behind our base branch. 0 means either not
// determined yet, or up to date with base branch. (We don't need to
// distinguish the two, as we don't draw anything in both cases.)
BehindBaseBranch atomic.Int32
}
func (b *Branch) FullRefName() string {

View file

@ -129,6 +129,9 @@ 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"`
// 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"`
// Height of the command log view
CommandLogSize int `yaml:"commandLogSize" jsonschema:"minimum=0"`
// Whether to split the main window when viewing file changes.
@ -673,27 +676,28 @@ func GetDefaultConfig() *UserConfig {
UnstagedChangesColor: []string{"red"},
DefaultFgColor: []string{"default"},
},
CommitLength: CommitLengthConfig{Show: true},
SkipNoStagedFilesWarning: false,
ShowListFooter: true,
ShowCommandLog: true,
ShowBottomLine: true,
ShowPanelJumps: true,
ShowFileTree: true,
ShowRandomTip: true,
ShowIcons: false,
NerdFontsVersion: "",
ShowFileIcons: true,
CommitHashLength: 8,
ShowBranchCommitHash: false,
CommandLogSize: 8,
SplitDiff: "auto",
SkipRewordInEditorWarning: false,
WindowSize: "normal",
Border: "rounded",
AnimateExplosion: true,
PortraitMode: "auto",
FilterMode: "substring",
CommitLength: CommitLengthConfig{Show: true},
SkipNoStagedFilesWarning: false,
ShowListFooter: true,
ShowCommandLog: true,
ShowBottomLine: true,
ShowPanelJumps: true,
ShowFileTree: true,
ShowRandomTip: true,
ShowIcons: false,
NerdFontsVersion: "",
ShowFileIcons: true,
CommitHashLength: 8,
ShowBranchCommitHash: false,
ShowDivergenceFromBaseBranch: "none",
CommandLogSize: 8,
SplitDiff: "auto",
SkipRewordInEditorWarning: false,
WindowSize: "normal",
Border: "rounded",
AnimateExplosion: true,
PortraitMode: "auto",
FilterMode: "substring",
Spinner: SpinnerConfig{
Frames: []string{"|", "/", "-", "\\"},
Rate: 50,

View file

@ -7,7 +7,12 @@ import (
)
func (config *UserConfig) Validate() error {
if err := validateEnum("gui.statusPanelView", config.Gui.StatusPanelView, []string{"dashboard", "allBranchesLog"}); err != nil {
if err := validateEnum("gui.statusPanelView", config.Gui.StatusPanelView,
[]string{"dashboard", "allBranchesLog"}); err != nil {
return err
}
if err := validateEnum("gui.showDivergenceFromBaseBranch", config.Gui.ShowDivergenceFromBaseBranch,
[]string{"none", "onlyArrow", "arrowAndNumber"}); err != nil {
return err
}
return nil

View file

@ -130,7 +130,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) error {
if self.c.AppState.LocalBranchSortOrder == "recency" {
refresh("reflog and branches", func() { self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex) })
} else {
refresh("branches", func() { self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex) })
refresh("branches", func() { self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true) })
refresh("reflog", func() { _ = self.refreshReflogCommits() })
}
} else if scopeSet.Includes(types.REBASE_COMMITS) {
@ -256,7 +256,7 @@ func (self *RefreshHelper) refreshReflogCommitsConsideringStartup() {
case types.INITIAL:
self.c.OnWorker(func(_ gocui.Task) error {
_ = self.refreshReflogCommits()
self.refreshBranches(false, true)
self.refreshBranches(false, true, true)
self.c.State().GetRepoState().SetStartupStage(types.COMPLETE)
return nil
})
@ -267,9 +267,11 @@ func (self *RefreshHelper) refreshReflogCommitsConsideringStartup() {
}
func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) {
loadBehindCounts := self.c.State().GetRepoState().GetStartupStage() == types.COMPLETE
self.refreshReflogCommitsConsideringStartup()
self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex)
self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, loadBehindCounts)
}
func (self *RefreshHelper) refreshCommitsAndCommitFiles() {
@ -438,7 +440,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() error {
// self.refreshStatus is called at the end of this because that's when we can
// be sure there is a State.Model.Branches array to pick the current branch from
func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) {
func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool) {
self.c.Mutexes().RefreshingBranchesMutex.Lock()
defer self.c.Mutexes().RefreshingBranchesMutex.Unlock()
@ -457,7 +459,25 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele
}
}
branches, err := self.c.Git().Loaders.BranchLoader.Load(reflogCommits)
branches, err := self.c.Git().Loaders.BranchLoader.Load(
reflogCommits,
self.c.Model().MainBranches,
self.c.Model().Branches,
loadBehindCounts,
func(f func() error) {
self.c.OnWorker(func(_ gocui.Task) error {
return f()
})
},
func() {
self.c.OnUIThread(func() error {
if err := self.c.Contexts().Branches.HandleRender(); err != nil {
self.c.Log.Error(err)
}
self.refreshStatus()
return nil
})
})
if err != nil {
self.c.Log.Error(err)
}

View file

@ -155,32 +155,38 @@ func BranchStatus(
return style.FgCyan.Sprintf("%s %s", itemOperationStr, utils.Loader(now, userConfig.Gui.Spinner))
}
if !branch.IsTrackingRemote() {
return ""
result := ""
if branch.IsTrackingRemote() {
if branch.UpstreamGone {
result = style.FgRed.Sprint(tr.UpstreamGone)
} else if branch.MatchesUpstream() {
result = style.FgGreen.Sprint("✓")
} else if branch.RemoteBranchNotStoredLocally() {
result = style.FgMagenta.Sprint("?")
} else if branch.IsBehindForPull() && branch.IsAheadForPull() {
result = style.FgYellow.Sprintf("↓%s↑%s", branch.BehindForPull, branch.AheadForPull)
} else if branch.IsBehindForPull() {
result = style.FgYellow.Sprintf("↓%s", branch.BehindForPull)
} else if branch.IsAheadForPull() {
result = style.FgYellow.Sprintf("↑%s", branch.AheadForPull)
}
}
if branch.UpstreamGone {
return style.FgRed.Sprint(tr.UpstreamGone)
if userConfig.Gui.ShowDivergenceFromBaseBranch != "none" {
behind := branch.BehindBaseBranch.Load()
if behind != 0 {
if result != "" {
result += " "
}
if userConfig.Gui.ShowDivergenceFromBaseBranch == "arrowAndNumber" {
result += style.FgCyan.Sprintf("↓%d", behind)
} else {
result += style.FgCyan.Sprintf("↓")
}
}
}
if branch.MatchesUpstream() {
return style.FgGreen.Sprint("✓")
}
if branch.RemoteBranchNotStoredLocally() {
return style.FgMagenta.Sprint("?")
}
if branch.IsBehindForPull() && branch.IsAheadForPull() {
return style.FgYellow.Sprintf("↓%s↑%s", branch.BehindForPull, branch.AheadForPull)
}
if branch.IsBehindForPull() {
return style.FgYellow.Sprintf("↓%s", branch.BehindForPull)
}
if branch.IsAheadForPull() {
return style.FgYellow.Sprintf("↑%s", branch.AheadForPull)
}
return ""
return result
}
func SetCustomBranches(customBranchColors map[string]string) {

View file

@ -2,6 +2,7 @@ package presentation
import (
"fmt"
"sync/atomic"
"testing"
"time"
@ -15,6 +16,11 @@ import (
"github.com/xo/terminfo"
)
func makeAtomic(v int32) (result atomic.Int32) {
result.Store(v)
return //nolint: nakedret
}
func Test_getBranchDisplayStrings(t *testing.T) {
scenarios := []struct {
branch *models.Branch
@ -23,6 +29,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth int
useIcons bool
checkedOutByWorktree bool
showDivergenceCfg string
expected []string
}{
// First some tests for when the view is wide enough so that everything fits:
@ -33,6 +40,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 100,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_name"},
},
{
@ -42,6 +50,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 100,
useIcons: false,
checkedOutByWorktree: true,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_name (worktree)"},
},
{
@ -51,6 +60,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 100,
useIcons: true,
checkedOutByWorktree: true,
showDivergenceCfg: "none",
expected: []string{"1m", "󰘬", "branch_name 󰌹"},
},
{
@ -66,6 +76,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 100,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_name ✓"},
},
{
@ -81,8 +92,57 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 100,
useIcons: false,
checkedOutByWorktree: true,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_name (worktree) ↓5↑3"},
},
{
branch: &models.Branch{
Name: "branch_name",
Recency: "1m",
BehindBaseBranch: makeAtomic(2),
},
itemOperation: types.ItemOperationNone,
fullDescription: false,
viewWidth: 100,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "onlyArrow",
expected: []string{"1m", "branch_name ↓"},
},
{
branch: &models.Branch{
Name: "branch_name",
Recency: "1m",
UpstreamRemote: "origin",
AheadForPull: "0",
BehindForPull: "0",
BehindBaseBranch: makeAtomic(2),
},
itemOperation: types.ItemOperationNone,
fullDescription: false,
viewWidth: 100,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "arrowAndNumber",
expected: []string{"1m", "branch_name ✓ ↓2"},
},
{
branch: &models.Branch{
Name: "branch_name",
Recency: "1m",
UpstreamRemote: "origin",
AheadForPull: "3",
BehindForPull: "5",
BehindBaseBranch: makeAtomic(2),
},
itemOperation: types.ItemOperationNone,
fullDescription: false,
viewWidth: 100,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "arrowAndNumber",
expected: []string{"1m", "branch_name ↓5↑3 ↓2"},
},
{
branch: &models.Branch{Name: "branch_name", Recency: "1m"},
itemOperation: types.ItemOperationPushing,
@ -90,6 +150,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 100,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_name Pushing |"},
},
{
@ -108,6 +169,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 100,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "12345678", "branch_name ✓", "origin branch_name", "commit title"},
},
@ -119,6 +181,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 14,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_na…"},
},
{
@ -128,6 +191,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 14,
useIcons: false,
checkedOutByWorktree: true,
showDivergenceCfg: "none",
expected: []string{"1m", "bra… (worktree)"},
},
{
@ -137,6 +201,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 14,
useIcons: true,
checkedOutByWorktree: true,
showDivergenceCfg: "none",
expected: []string{"1m", "󰘬", "branc… 󰌹"},
},
{
@ -152,6 +217,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 14,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_… ✓"},
},
{
@ -167,6 +233,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 30,
useIcons: false,
checkedOutByWorktree: true,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_na… (worktree) ↓5↑3"},
},
{
@ -176,6 +243,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 20,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "branc… Pushing |"},
},
{
@ -185,6 +253,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: -1,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "abc Pushing |"},
},
{
@ -194,6 +263,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: -1,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "ab Pushing |"},
},
{
@ -203,6 +273,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: -1,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "a Pushing |"},
},
{
@ -221,6 +292,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
viewWidth: 20,
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "12345678", "bran… ✓", "origin branch_name", "commit title"},
},
}
@ -232,6 +304,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
for i, s := range scenarios {
icons.SetNerdFontsVersion(lo.Ternary(s.useIcons, "3", ""))
c.UserConfig.Gui.ShowDivergenceFromBaseBranch = s.showDivergenceCfg
worktrees := []*models.Worktree{}
if s.checkedOutByWorktree {

View file

@ -0,0 +1,27 @@
package status
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ShowDivergenceFromBaseBranch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Show divergence from base branch in the status panel",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.UserConfig.Gui.ShowDivergenceFromBaseBranch = "arrowAndNumber"
},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(2)
shell.CloneIntoRemote("origin")
shell.NewBranch("feature")
shell.HardReset("HEAD^")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.GlobalPress(keys.Universal.NextBlock)
t.Views().Status().
Content(Equals("↓1 repo → feature"))
},
})

View file

@ -266,6 +266,7 @@ var tests = []*components.IntegrationTest{
status.ClickRepoNameToOpenReposMenu,
status.ClickToFocus,
status.ClickWorkingTreeStateToOpenRebaseOptionsMenu,
status.ShowDivergenceFromBaseBranch,
submodule.Add,
submodule.Enter,
submodule.EnterNested,

View file

@ -331,6 +331,16 @@
"description": "If true, show commit hashes alongside branch names in the branches view.",
"default": false
},
"showDivergenceFromBaseBranch": {
"type": "string",
"enum": [
"none",
"onlyArrow",
"arrowAndNumber"
],
"description": "Whether to show the divergence from the base branch in the branches view.\nOne of: 'none' | 'onlyArrow' | 'arrowAndNumber'",
"default": "none"
},
"commandLogSize": {
"type": "integer",
"minimum": 0,