diff --git a/docs/Config.md b/docs/Config.md index 5662a4d7378..7cc602c0ba2 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 16777243a86..929d5964d3f 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,108 @@ 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) +// +// Note that this function may return an empty string even if the returned error +// is nil, e.g. when none of the configured main branches exist. This is not +// considered an error condition, so callers need to check both the returned +// error and whether the returned base branch is empty (and possibly react +// differently in both cases). +func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *MainBranches) (string, error) { + mergeBase := mainBranches.GetMergeBase(branch.FullRefName()) + if mergeBase == "" { + return "", nil + } + + output, err := self.cmd.New( + NewGitCmd("for-each-ref"). + Arg("--contains"). + Arg(mergeBase). + Arg("--format=%(refname)"). + Arg(mainBranches.Get()...). + ToArgv(), + ).DontLog().RunWithOutput() + if err != nil { + return "", err + } + trimmedOutput := strings.TrimSpace(output) + split := strings.Split(trimmedOutput, "\n") + if len(split) == 0 || split[0] == "" { + return "", nil + } + return split[0], nil +} + func (self *BranchLoader) obtainBranches(canUsePushTrack bool) []*models.Branch { output, err := self.getRawBranches() if err != nil { diff --git a/pkg/commands/git_commands/commit_loader.go b/pkg/commands/git_commands/commit_loader.go index 737e4c07731..f116ded1f97 100644 --- a/pkg/commands/git_commands/commit_loader.go +++ b/pkg/commands/git_commands/commit_loader.go @@ -35,11 +35,6 @@ type CommitLoader struct { 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. - // We use these to obtain the merge base of the branch. - // When nil, we're yet to obtain the list of existing main branches. - // When an empty slice, we've obtained the list and it's empty. - mainBranches []string *GitCommon } @@ -56,7 +51,6 @@ func NewCommitLoader( getRebaseMode: getRebaseMode, readFile: os.ReadFile, walkFiles: filepath.Walk, - mainBranches: nil, GitCommon: gitCommon, } } @@ -72,6 +66,7 @@ type GetCommitsOptions struct { All bool // If non-empty, show divergence from this ref (left-right log) RefToShowDivergenceFrom string + MainBranches *MainBranches } // GetCommits obtains the commits of the current branch @@ -108,9 +103,9 @@ func (self *CommitLoader) GetCommits(opts GetCommitsOptions) ([]*models.Commit, go utils.Safe(func() { defer wg.Done() - ancestor = self.getMergeBase(opts.RefName) + ancestor = opts.MainBranches.GetMergeBase(opts.RefName) if opts.RefToShowDivergenceFrom != "" { - remoteAncestor = self.getMergeBase(opts.RefToShowDivergenceFrom) + remoteAncestor = opts.MainBranches.GetMergeBase(opts.RefToShowDivergenceFrom) } }) @@ -471,82 +466,6 @@ func setCommitMergedStatuses(ancestor string, commits []*models.Commit) { } } -func (self *CommitLoader) getMergeBase(refName string) string { - if self.mainBranches == nil { - self.mainBranches = self.getExistingMainBranches() - } - - if len(self.mainBranches) == 0 { - 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( - NewGitCmd("merge-base").Arg(refName).Arg(self.mainBranches...). - ToArgv(), - ).DontLog().RunWithOutput() - if err != nil { - // 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.mainBranches = nil - } - return ignoringWarnings(output) -} - -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) - go utils.Safe(func() { - defer wg.Done() - - // 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, 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() - - existingBranches = lo.Filter(existingBranches, func(branch string, _ int) bool { - return branch != "" - }) - - return existingBranches -} - func ignoringWarnings(commandOutput string) string { trimmedOutput := strings.TrimSpace(commandOutput) split := strings.Split(trimmedOutput, "\n") diff --git a/pkg/commands/git_commands/commit_loader_test.go b/pkg/commands/git_commands/commit_loader_test.go index fe4f395855a..a8ef9e69a44 100644 --- a/pkg/commands/git_commands/commit_loader_test.go +++ b/pkg/commands/git_commands/commit_loader_test.go @@ -307,10 +307,11 @@ func TestGetCommits(t *testing.T) { common := utils.NewDummyCommon() common.AppState = &config.AppState{} common.AppState.GitLogOrder = scenario.logOrder + cmd := oscommands.NewDummyCmdObjBuilder(scenario.runner) builder := &CommitLoader{ Common: common, - cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner), + cmd: cmd, getRebaseMode: func() (enums.RebaseMode, error) { return scenario.rebaseMode, nil }, dotGitDir: ".git", readFile: func(filename string) ([]byte, error) { @@ -322,7 +323,9 @@ func TestGetCommits(t *testing.T) { } common.UserConfig.Git.MainBranches = scenario.mainBranches - commits, err := builder.GetCommits(scenario.opts) + opts := scenario.opts + opts.MainBranches = NewMainBranches(scenario.mainBranches, cmd) + commits, err := builder.GetCommits(opts) assert.Equal(t, scenario.expectedCommits, commits) assert.Equal(t, scenario.expectedError, err) diff --git a/pkg/commands/git_commands/main_branches.go b/pkg/commands/git_commands/main_branches.go new file mode 100644 index 00000000000..341232b04a2 --- /dev/null +++ b/pkg/commands/git_commands/main_branches.go @@ -0,0 +1,122 @@ +package git_commands + +import ( + "strings" + "sync" + + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" + "github.com/sasha-s/go-deadlock" +) + +type MainBranches struct { + // List of main branches configured by the user. Just the bare names. + configuredMainBranches []string + // Which of these actually exist in the repository. Full ref names, and it + // could be either "refs/heads/..." or "refs/remotes/origin/..." depending + // on which one exists for a given bare name. + existingMainBranches []string + + cmd oscommands.ICmdObjBuilder + mutex *deadlock.Mutex +} + +func NewMainBranches( + configuredMainBranches []string, + cmd oscommands.ICmdObjBuilder, +) *MainBranches { + return &MainBranches{ + configuredMainBranches: configuredMainBranches, + existingMainBranches: nil, + cmd: cmd, + mutex: &deadlock.Mutex{}, + } +} + +// Get the list of main branches that exist in the repository. This is a list of +// full ref names. +func (self *MainBranches) Get() []string { + self.mutex.Lock() + defer self.mutex.Unlock() + + if self.existingMainBranches == nil { + self.existingMainBranches = self.determineMainBranches() + } + + return self.existingMainBranches +} + +// Return the merge base of the given refName with the closest main branch. +func (self *MainBranches) GetMergeBase(refName string) string { + mainBranches := self.Get() + if len(mainBranches) == 0 { + return "" + } + + // We pass all existing main branches to the merge-base call; git will + // return the base commit for the closest one. + + // We ignore errors from this call, since we can't distinguish whether the + // error is because one of the main branches has been deleted since the last + // call to determineMainBranches, or because the refName has no common + // history with any of the main branches. Since the former should happen + // very rarely, users must quit and restart lazygit to fix it; the latter is + // also not very common, but can totally happen and is not an error. + + output, _ := self.cmd.New( + NewGitCmd("merge-base").Arg(refName).Arg(mainBranches...). + ToArgv(), + ).DontLog().RunWithOutput() + return ignoringWarnings(output) +} + +func (self *MainBranches) determineMainBranches() []string { + var existingBranches []string + var wg sync.WaitGroup + + existingBranches = make([]string, len(self.configuredMainBranches)) + + for i, branchName := range self.configuredMainBranches { + wg.Add(1) + go utils.Safe(func() { + defer wg.Done() + + // 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, 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() + + existingBranches = lo.Filter(existingBranches, func(branch string, _ int) bool { + return branch != "" + }) + + return existingBranches +} diff --git a/pkg/commands/models/branch.go b/pkg/commands/models/branch.go index 5a34ba5e8fe..04f869ebdbd 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 41d3dfe1072..c8895710ea6 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 945979db96b..403119adadb 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 b927296fccb..4872add08a6 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() { @@ -331,6 +333,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit() error { RefName: self.refForLog(), RefForPushedStatus: checkedOutBranchName, All: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), + MainBranches: self.c.Model().MainBranches, }, ) if err != nil { @@ -357,6 +360,7 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit() error { RefName: self.c.Contexts().SubCommits.GetRef().FullRefName(), RefToShowDivergenceFrom: self.c.Contexts().SubCommits.GetRefToShowDivergenceFrom(), RefForPushedStatus: self.c.Contexts().SubCommits.GetRef().FullRefName(), + MainBranches: self.c.Model().MainBranches, }, ) if err != nil { @@ -436,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() @@ -455,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/controllers/helpers/sub_commits_helper.go b/pkg/gui/controllers/helpers/sub_commits_helper.go index c31d5093709..f1cecf7f579 100644 --- a/pkg/gui/controllers/helpers/sub_commits_helper.go +++ b/pkg/gui/controllers/helpers/sub_commits_helper.go @@ -44,6 +44,7 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error { RefName: opts.Ref.FullRefName(), RefForPushedStatus: opts.Ref.FullRefName(), RefToShowDivergenceFrom: opts.RefToShowDivergenceFrom, + MainBranches: self.c.Model().MainBranches, }, ) if err != nil { diff --git a/pkg/gui/controllers/status_controller.go b/pkg/gui/controllers/status_controller.go index 4c4384bfd9a..483acdda6a9 100644 --- a/pkg/gui/controllers/status_controller.go +++ b/pkg/gui/controllers/status_controller.go @@ -12,6 +12,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -116,7 +117,7 @@ func (self *StatusController) onClick(opts gocui.ViewMouseBindingOpts) error { return err } - upstreamStatus := presentation.BranchStatus(currentBranch, types.ItemOperationNone, self.c.Tr, time.Now(), self.c.UserConfig) + upstreamStatus := utils.Decolorise(presentation.BranchStatus(currentBranch, types.ItemOperationNone, self.c.Tr, time.Now(), self.c.UserConfig)) repoName := self.c.Git().RepoPaths.RepoName() workingTreeState := self.c.Git().Status.WorkingTreeState() switch workingTreeState { diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 0c0f363709e..5f2fd55bfd9 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -379,6 +379,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { BisectInfo: git_commands.NewNullBisectInfo(), FilesTrie: patricia.NewTrie(), Authors: map[string]*models.Author{}, + MainBranches: git_commands.NewMainBranches(gui.UserConfig.Git.MainBranches, gui.os.Cmd), }, Modes: &types.Modes{ Filtering: filtering.New(startArgs.FilterPath, ""), diff --git a/pkg/gui/presentation/branches.go b/pkg/gui/presentation/branches.go index 406a580d5fd..aab51fe6153 100644 --- a/pkg/gui/presentation/branches.go +++ b/pkg/gui/presentation/branches.go @@ -56,7 +56,7 @@ func getBranchDisplayStrings( // Recency is always three characters, plus one for the space availableWidth := viewWidth - 4 if len(branchStatus) > 0 { - availableWidth -= runewidth.StringWidth(branchStatus) + 1 + availableWidth -= runewidth.StringWidth(utils.Decolorise(branchStatus)) + 1 } if icons.IsIconEnabled() { availableWidth -= 2 // one for the icon, one for the space @@ -89,8 +89,7 @@ func getBranchDisplayStrings( coloredName = fmt.Sprintf("%s %s", coloredName, style.FgDefault.Sprint(worktreeIcon)) } if len(branchStatus) > 0 { - coloredStatus := branchStatusColor(b, itemOperation).Sprint(branchStatus) - coloredName = fmt.Sprintf("%s %s", coloredName, coloredStatus) + coloredName = fmt.Sprintf("%s %s", coloredName, branchStatus) } recencyColor := style.FgCyan @@ -144,30 +143,6 @@ func GetBranchTextStyle(name string) style.TextStyle { } } -func branchStatusColor(branch *models.Branch, itemOperation types.ItemOperation) style.TextStyle { - colour := style.FgYellow - if itemOperation != types.ItemOperationNone { - colour = style.FgCyan - } else if branch.UpstreamGone { - colour = style.FgRed - } else if branch.MatchesUpstream() { - colour = style.FgGreen - } else if branch.RemoteBranchNotStoredLocally() { - colour = style.FgMagenta - } - - return colour -} - -func ColoredBranchStatus( - branch *models.Branch, - itemOperation types.ItemOperation, - tr *i18n.TranslationSet, - userConfig *config.UserConfig, -) string { - return branchStatusColor(branch, itemOperation).Sprint(BranchStatus(branch, itemOperation, tr, time.Now(), userConfig)) -} - func BranchStatus( branch *models.Branch, itemOperation types.ItemOperation, @@ -177,30 +152,38 @@ func BranchStatus( ) string { itemOperationStr := ItemOperationToString(itemOperation, tr) if itemOperationStr != "" { - return itemOperationStr + " " + utils.Loader(now, userConfig.Gui.Spinner) - } - - if !branch.IsTrackingRemote() { - return "" - } - - if branch.UpstreamGone { - return tr.UpstreamGone - } - - if branch.MatchesUpstream() { - return "✓" - } - if branch.RemoteBranchNotStoredLocally() { - return "?" + return style.FgCyan.Sprintf("%s %s", itemOperationStr, utils.Loader(now, userConfig.Gui.Spinner)) } result := "" - if branch.IsAheadForPull() { - result = fmt.Sprintf("↑%s", branch.AheadForPull) - } - if branch.IsBehindForPull() { - result = fmt.Sprintf("%s↓%s", result, branch.BehindForPull) + 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 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("↓") + } + } } return result diff --git a/pkg/gui/presentation/branches_test.go b/pkg/gui/presentation/branches_test.go index cf2f1d994f8..ba79f16ce95 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,7 +92,56 @@ func Test_getBranchDisplayStrings(t *testing.T) { viewWidth: 100, useIcons: false, checkedOutByWorktree: true, - expected: []string{"1m", "branch_name (worktree) ↑3↓5"}, + 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"}, @@ -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,7 +233,8 @@ func Test_getBranchDisplayStrings(t *testing.T) { viewWidth: 30, useIcons: false, checkedOutByWorktree: true, - expected: []string{"1m", "branch_na… (worktree) ↑3↓5"}, + showDivergenceCfg: "none", + expected: []string{"1m", "branch_na… (worktree) ↓5↑3"}, }, { branch: &models.Branch{Name: "branch_name", Recency: "1m"}, @@ -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/gui/presentation/status.go b/pkg/gui/presentation/status.go index d3686510eb4..b3b2100673f 100644 --- a/pkg/gui/presentation/status.go +++ b/pkg/gui/presentation/status.go @@ -2,6 +2,7 @@ package presentation import ( "fmt" + "time" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/types/enums" @@ -24,7 +25,10 @@ func FormatStatus( status := "" if currentBranch.IsRealBranch() { - status += ColoredBranchStatus(currentBranch, itemOperation, tr, userConfig) + " " + status += BranchStatus(currentBranch, itemOperation, tr, time.Now(), userConfig) + if status != "" { + status += " " + } } if workingTreeState != enums.REBASE_MODE_NONE { @@ -40,7 +44,7 @@ func FormatStatus( } repoName = fmt.Sprintf("%s(%s%s)", repoName, icon, style.FgCyan.Sprint(linkedWorktreeName)) } - status += fmt.Sprintf("%s → %s ", repoName, name) + status += fmt.Sprintf("%s → %s", repoName, name) return status } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index d5017307820..77f2f56eb62 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -281,6 +281,8 @@ type Model struct { // we're on a detached head because we're rebasing or bisecting. CheckedOutBranch string + MainBranches *git_commands.MainBranches + // for displaying suggestions while typing in a file name FilesTrie *patricia.Trie diff --git a/pkg/integration/tests/branch/delete_remote_branch_with_credential_prompt.go b/pkg/integration/tests/branch/delete_remote_branch_with_credential_prompt.go index ad8f70c435c..6e3f5202855 100644 --- a/pkg/integration/tests/branch/delete_remote_branch_with_credential_prompt.go +++ b/pkg/integration/tests/branch/delete_remote_branch_with_credential_prompt.go @@ -44,7 +44,7 @@ var DeleteRemoteBranchWithCredentialPrompt = NewIntegrationTest(NewIntegrationTe Confirm() } - t.Views().Status().Content(Contains("✓ repo → mybranch")) + t.Views().Status().Content(Equals("✓ repo → mybranch")) deleteBranch() @@ -66,7 +66,7 @@ var DeleteRemoteBranchWithCredentialPrompt = NewIntegrationTest(NewIntegrationTe Content(Contains("incorrect username/password")). Confirm() - t.Views().Status().Content(Contains("✓ repo → mybranch")) + t.Views().Status().Content(Equals("✓ repo → mybranch")) // try again with correct password deleteBranch() @@ -81,7 +81,7 @@ var DeleteRemoteBranchWithCredentialPrompt = NewIntegrationTest(NewIntegrationTe Type("password"). Confirm() - t.Views().Status().Content(Contains("repo → mybranch").DoesNotContain("✓")) + t.Views().Status().Content(Equals("(upstream gone) repo → mybranch")) t.Views().Branches().TopLines(Contains("mybranch (upstream gone)")) }, }) 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 00000000000..53ab0ab2f25 --- /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/sync/force_push.go b/pkg/integration/tests/sync/force_push.go index 89d8a837183..e563cfd2821 100644 --- a/pkg/integration/tests/sync/force_push.go +++ b/pkg/integration/tests/sync/force_push.go @@ -26,7 +26,7 @@ var ForcePush = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("↓1 repo → master")) + t.Views().Status().Content(Equals("↓1 repo → master")) t.Views().Files().IsFocused().Press(keys.Universal.Push) @@ -40,7 +40,7 @@ var ForcePush = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("✓ repo → master")) + t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Remotes().Focus(). Lines(Contains("origin")). diff --git a/pkg/integration/tests/sync/force_push_multiple_matching.go b/pkg/integration/tests/sync/force_push_multiple_matching.go index 37c43f2641c..63825ee4f7c 100644 --- a/pkg/integration/tests/sync/force_push_multiple_matching.go +++ b/pkg/integration/tests/sync/force_push_multiple_matching.go @@ -22,7 +22,7 @@ var ForcePushMultipleMatching = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("↓1 repo → master")) + t.Views().Status().Content(Equals("↓1 repo → master")) t.Views().Branches(). Lines( @@ -42,7 +42,7 @@ var ForcePushMultipleMatching = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("✓ repo → master")) + t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Branches(). Lines( diff --git a/pkg/integration/tests/sync/force_push_multiple_upstream.go b/pkg/integration/tests/sync/force_push_multiple_upstream.go index 1ea51272087..8c55b7e8ce9 100644 --- a/pkg/integration/tests/sync/force_push_multiple_upstream.go +++ b/pkg/integration/tests/sync/force_push_multiple_upstream.go @@ -21,7 +21,7 @@ var ForcePushMultipleUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("↓1 repo → master")) + t.Views().Status().Content(Equals("↓1 repo → master")) t.Views().Branches(). Lines( @@ -41,7 +41,7 @@ var ForcePushMultipleUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("✓ repo → master")) + t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Branches(). Lines( diff --git a/pkg/integration/tests/sync/pull.go b/pkg/integration/tests/sync/pull.go index 7f570379129..b30cbb40836 100644 --- a/pkg/integration/tests/sync/pull.go +++ b/pkg/integration/tests/sync/pull.go @@ -26,7 +26,7 @@ var Pull = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("↓1 repo → master")) + t.Views().Status().Content(Equals("↓1 repo → master")) t.Views().Files().IsFocused().Press(keys.Universal.Pull) @@ -36,6 +36,6 @@ var Pull = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("✓ repo → master")) + t.Views().Status().Content(Equals("✓ repo → master")) }, }) diff --git a/pkg/integration/tests/sync/pull_and_set_upstream.go b/pkg/integration/tests/sync/pull_and_set_upstream.go index b2dbfddb49a..acffa24be84 100644 --- a/pkg/integration/tests/sync/pull_and_set_upstream.go +++ b/pkg/integration/tests/sync/pull_and_set_upstream.go @@ -25,7 +25,7 @@ var PullAndSetUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("repo → master")) + t.Views().Status().Content(Equals("repo → master")) t.Views().Files().IsFocused().Press(keys.Universal.Pull) @@ -40,6 +40,6 @@ var PullAndSetUpstream = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("✓ repo → master")) + t.Views().Status().Content(Equals("✓ repo → master")) }, }) diff --git a/pkg/integration/tests/sync/pull_merge.go b/pkg/integration/tests/sync/pull_merge.go index d9c9e107d76..39e447ebc3b 100644 --- a/pkg/integration/tests/sync/pull_merge.go +++ b/pkg/integration/tests/sync/pull_merge.go @@ -33,13 +33,13 @@ var PullMerge = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("↓2 repo → master")) + t.Views().Status().Content(Equals("↓2↑1 repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Pull) - t.Views().Status().Content(Contains("↑2 repo → master")) + t.Views().Status().Content(Equals("↑2 repo → master")) t.Views().Commits(). Lines( diff --git a/pkg/integration/tests/sync/pull_merge_conflict.go b/pkg/integration/tests/sync/pull_merge_conflict.go index 3177cabe5ff..2161f6abd97 100644 --- a/pkg/integration/tests/sync/pull_merge_conflict.go +++ b/pkg/integration/tests/sync/pull_merge_conflict.go @@ -34,7 +34,7 @@ var PullMergeConflict = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("↓2 repo → master")) + t.Views().Status().Content(Equals("↓2↑1 repo → master")) t.Views().Files(). IsFocused(). @@ -62,7 +62,7 @@ var PullMergeConflict = NewIntegrationTest(NewIntegrationTestArgs{ t.Common().ContinueOnConflictsResolved() - t.Views().Status().Content(Contains("↑2 repo → master")) + t.Views().Status().Content(Equals("↑2 repo → master")) t.Views().Commits(). Focus(). diff --git a/pkg/integration/tests/sync/pull_rebase.go b/pkg/integration/tests/sync/pull_rebase.go index 77810426e78..a2657ffe627 100644 --- a/pkg/integration/tests/sync/pull_rebase.go +++ b/pkg/integration/tests/sync/pull_rebase.go @@ -35,13 +35,13 @@ var PullRebase = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("↓2 repo → master")) + t.Views().Status().Content(Equals("↓2↑1 repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Pull) - t.Views().Status().Content(Contains("↑1 repo → master")) + t.Views().Status().Content(Equals("↑1 repo → master")) t.Views().Commits(). Lines( diff --git a/pkg/integration/tests/sync/pull_rebase_conflict.go b/pkg/integration/tests/sync/pull_rebase_conflict.go index 3b6c83b85d8..d9541e0edfa 100644 --- a/pkg/integration/tests/sync/pull_rebase_conflict.go +++ b/pkg/integration/tests/sync/pull_rebase_conflict.go @@ -34,7 +34,7 @@ var PullRebaseConflict = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("↓2 repo → master")) + t.Views().Status().Content(Equals("↓2↑1 repo → master")) t.Views().Files(). IsFocused(). @@ -63,7 +63,7 @@ var PullRebaseConflict = NewIntegrationTest(NewIntegrationTestArgs{ t.Common().ContinueOnConflictsResolved() - t.Views().Status().Content(Contains("↑1 repo → master")) + t.Views().Status().Content(Equals("↑1 repo → master")) t.Views().Commits(). Focus(). diff --git a/pkg/integration/tests/sync/pull_rebase_interactive_conflict.go b/pkg/integration/tests/sync/pull_rebase_interactive_conflict.go index a6a3f535657..bf0fc050b92 100644 --- a/pkg/integration/tests/sync/pull_rebase_interactive_conflict.go +++ b/pkg/integration/tests/sync/pull_rebase_interactive_conflict.go @@ -38,7 +38,7 @@ var PullRebaseInteractiveConflict = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), ) - t.Views().Status().Content(Contains("↓2 repo → master")) + t.Views().Status().Content(Equals("↓2↑2 repo → master")) t.Views().Files(). IsFocused(). @@ -76,7 +76,7 @@ var PullRebaseInteractiveConflict = NewIntegrationTest(NewIntegrationTestArgs{ t.Common().ContinueOnConflictsResolved() - t.Views().Status().Content(Contains("↑2 repo → master")) + t.Views().Status().Content(Equals("↑2 repo → master")) t.Views().Commits(). Focus(). diff --git a/pkg/integration/tests/sync/pull_rebase_interactive_conflict_drop.go b/pkg/integration/tests/sync/pull_rebase_interactive_conflict_drop.go index b5379096442..3eee12efda0 100644 --- a/pkg/integration/tests/sync/pull_rebase_interactive_conflict_drop.go +++ b/pkg/integration/tests/sync/pull_rebase_interactive_conflict_drop.go @@ -38,7 +38,7 @@ var PullRebaseInteractiveConflictDrop = NewIntegrationTest(NewIntegrationTestArg Contains("one"), ) - t.Views().Status().Content(Contains("↓2 repo → master")) + t.Views().Status().Content(Equals("↓2↑2 repo → master")) t.Views().Files(). IsFocused(). @@ -85,7 +85,7 @@ var PullRebaseInteractiveConflictDrop = NewIntegrationTest(NewIntegrationTestArg t.Common().ContinueOnConflictsResolved() - t.Views().Status().Content(Contains("↑1 repo → master")) + t.Views().Status().Content(Equals("↑1 repo → master")) t.Views().Commits(). Focus(). diff --git a/pkg/integration/tests/sync/push.go b/pkg/integration/tests/sync/push.go index ea27b399c3e..cb1e11aa91b 100644 --- a/pkg/integration/tests/sync/push.go +++ b/pkg/integration/tests/sync/push.go @@ -21,7 +21,7 @@ var Push = NewIntegrationTest(NewIntegrationTestArgs{ shell.EmptyCommit("two") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Status().Content(Contains("↑1 repo → master")) + t.Views().Status().Content(Equals("↑1 repo → master")) t.Views().Files(). IsFocused(). diff --git a/pkg/integration/tests/sync/push_and_auto_set_upstream.go b/pkg/integration/tests/sync/push_and_auto_set_upstream.go index c3f58d644f1..d8a336ea45f 100644 --- a/pkg/integration/tests/sync/push_and_auto_set_upstream.go +++ b/pkg/integration/tests/sync/push_and_auto_set_upstream.go @@ -22,7 +22,7 @@ var PushAndAutoSetUpstream = NewIntegrationTest(NewIntegrationTestArgs{ }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // assert no mention of upstream/downstream changes - t.Views().Status().Content(MatchesRegexp(`^\s+repo → master`)) + t.Views().Status().Content(Equals("repo → master")) t.Views().Files(). IsFocused(). diff --git a/pkg/integration/tests/sync/push_and_set_upstream.go b/pkg/integration/tests/sync/push_and_set_upstream.go index 0521c4b2102..d900452eba0 100644 --- a/pkg/integration/tests/sync/push_and_set_upstream.go +++ b/pkg/integration/tests/sync/push_and_set_upstream.go @@ -19,7 +19,7 @@ var PushAndSetUpstream = NewIntegrationTest(NewIntegrationTestArgs{ }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // assert no mention of upstream/downstream changes - t.Views().Status().Content(MatchesRegexp(`^\s+repo → master`)) + t.Views().Status().Content(Equals("repo → master")) t.Views().Files(). IsFocused(). diff --git a/pkg/integration/tests/sync/push_follow_tags.go b/pkg/integration/tests/sync/push_follow_tags.go index 463172abf97..c293cf005f8 100644 --- a/pkg/integration/tests/sync/push_follow_tags.go +++ b/pkg/integration/tests/sync/push_follow_tags.go @@ -24,13 +24,13 @@ var PushFollowTags = NewIntegrationTest(NewIntegrationTestArgs{ shell.SetConfig("push.followTags", "true") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Status().Content(Contains("↑1 repo → master")) + t.Views().Status().Content(Equals("↑1 repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Push) - t.Views().Status().Content(Contains("✓ repo → master")) + t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Remotes(). Focus(). diff --git a/pkg/integration/tests/sync/push_no_follow_tags.go b/pkg/integration/tests/sync/push_no_follow_tags.go index 599d05a64eb..18a1cf62d93 100644 --- a/pkg/integration/tests/sync/push_no_follow_tags.go +++ b/pkg/integration/tests/sync/push_no_follow_tags.go @@ -22,13 +22,13 @@ var PushNoFollowTags = NewIntegrationTest(NewIntegrationTestArgs{ shell.CreateAnnotatedTag("mytag", "message", "HEAD") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Status().Content(Contains("✓ repo → master")) + t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Files(). IsFocused(). Press(keys.Universal.Push) - t.Views().Status().Content(Contains("✓ repo → master")) + t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Remotes(). Focus(). diff --git a/pkg/integration/tests/sync/push_with_credential_prompt.go b/pkg/integration/tests/sync/push_with_credential_prompt.go index 79d7fcc1dcb..62be89bf438 100644 --- a/pkg/integration/tests/sync/push_with_credential_prompt.go +++ b/pkg/integration/tests/sync/push_with_credential_prompt.go @@ -26,7 +26,7 @@ var PushWithCredentialPrompt = NewIntegrationTest(NewIntegrationTestArgs{ shell.CopyHelpFile("pre-push", ".git/hooks/pre-push") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Status().Content(Contains("↑1 repo → master")) + t.Views().Status().Content(Equals("↑1 repo → master")) t.Views().Files(). IsFocused(). @@ -50,7 +50,7 @@ var PushWithCredentialPrompt = NewIntegrationTest(NewIntegrationTestArgs{ Content(Contains("incorrect username/password")). Confirm() - t.Views().Status().Content(Contains("↑1 repo → master")) + t.Views().Status().Content(Equals("↑1 repo → master")) // try again with correct password t.Views().Files(). @@ -67,7 +67,7 @@ var PushWithCredentialPrompt = NewIntegrationTest(NewIntegrationTestArgs{ Type("password"). Confirm() - t.Views().Status().Content(Contains("✓ repo → master")) + t.Views().Status().Content(Equals("✓ repo → master")) assertSuccessfullyPushed(t) }, diff --git a/pkg/integration/tests/sync/shared.go b/pkg/integration/tests/sync/shared.go index 8ea9a6c257e..3e3d5c017c9 100644 --- a/pkg/integration/tests/sync/shared.go +++ b/pkg/integration/tests/sync/shared.go @@ -24,7 +24,7 @@ func createTwoBranchesReadyToForcePush(shell *Shell) { } func assertSuccessfullyPushed(t *TestDriver) { - t.Views().Status().Content(Contains("✓ repo → master")) + t.Views().Status().Content(Equals("✓ repo → master")) t.Views().Remotes(). Focus(). diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index d4a093de851..0ab0e43318f 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 f5f7bab86ef..cf25ce00778 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,