From 373b1970cab78b639bbdb5e5a7c684735f4d95a7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Apr 2024 12:34:05 +0200 Subject: [PATCH] Show divergence from base branch in branches list --- docs/Config.md | 4 + pkg/commands/git_commands/branch_loader.go | 74 ++++++++++++++++++- pkg/commands/models/branch.go | 10 ++- pkg/config/user_config.go | 46 ++++++------ pkg/config/user_config_validation.go | 7 +- pkg/gui/controllers/helpers/refresh_helper.go | 30 ++++++-- pkg/gui/presentation/branches.go | 50 +++++++------ pkg/gui/presentation/branches_test.go | 73 ++++++++++++++++++ .../show_divergence_from_base_branch.go | 27 +++++++ pkg/integration/tests/test_list.go | 1 + schema/config.json | 10 +++ 11 files changed, 281 insertions(+), 51 deletions(-) create mode 100644 pkg/integration/tests/status/show_divergence_from_base_branch.go diff --git a/docs/Config.md b/docs/Config.md index 5662a4d73..7cc602c0b 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -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 diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index 1052626a9..929d5964d 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -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 "\t" + 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) // diff --git a/pkg/commands/models/branch.go b/pkg/commands/models/branch.go index 5a34ba5e8..04f869ebd 100644 --- a/pkg/commands/models/branch.go +++ b/pkg/commands/models/branch.go @@ -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 { diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 41d3dfe10..c8895710e 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -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, diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 945979db9..403119ada 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -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 diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 02a26ded9..4872add08 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -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) } diff --git a/pkg/gui/presentation/branches.go b/pkg/gui/presentation/branches.go index 0e48a3935..aab51fe61 100644 --- a/pkg/gui/presentation/branches.go +++ b/pkg/gui/presentation/branches.go @@ -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) { diff --git a/pkg/gui/presentation/branches_test.go b/pkg/gui/presentation/branches_test.go index db4868970..ba79f16ce 100644 --- a/pkg/gui/presentation/branches_test.go +++ b/pkg/gui/presentation/branches_test.go @@ -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 { diff --git a/pkg/integration/tests/status/show_divergence_from_base_branch.go b/pkg/integration/tests/status/show_divergence_from_base_branch.go new file mode 100644 index 000000000..53ab0ab2f --- /dev/null +++ b/pkg/integration/tests/status/show_divergence_from_base_branch.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index d4a093de8..0ab0e4331 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -266,6 +266,7 @@ var tests = []*components.IntegrationTest{ status.ClickRepoNameToOpenReposMenu, status.ClickToFocus, status.ClickWorkingTreeStateToOpenRebaseOptionsMenu, + status.ShowDivergenceFromBaseBranch, submodule.Add, submodule.Enter, submodule.EnterNested, diff --git a/schema/config.json b/schema/config.json index f5f7bab86..cf25ce007 100644 --- a/schema/config.json +++ b/schema/config.json @@ -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,