1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-04-25 12:24:47 +02:00

Divergence from base branch display (#3613)

- **PR Description**

Add a new config option `showDivergenceFromBaseBranch`; if not "none",
it indicates in the branches view for each branch if it has fallen
behind its base branch, and optionally by how much. If set to
"onlyArrow", it will append `↓` after the branch status; if set to
"arrowAndNumber", it appends `↓17`, where the count indicates how many
commits it is behind the base branch. These are colored in blue, and go
after the existing yellow `↓3↑7` indication of divergence from the
upstream.

The option is off by default, since we are afraid that people may find
this too noisy. We may reconsider this choice in the future if the
response is positive.

- **Please check if the PR fulfills these requirements**

* [x] Cheatsheets are up-to-date (run `go generate ./...`)
* [x] Code has been formatted (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting))
* [x] Tests have been added/updated (see
[here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md)
for the integration test guide)
* [x] Text is internationalised (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation))
* [x] Docs have been updated if necessary
* [x] You've read through your own file changes for silly mistakes etc
This commit is contained in:
Stefan Haller 2024-06-03 14:01:43 +02:00 committed by GitHub
commit 4ac77f4575
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 498 additions and 203 deletions

View File

@ -187,6 +187,10 @@ gui:
# If true, show commit hashes alongside branch names in the branches view. # If true, show commit hashes alongside branch names in the branches view.
showBranchCommitHash: false 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 # Height of the command log view
commandLogSize: 8 commandLogSize: 8

View File

@ -5,6 +5,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/jesseduffield/generics/set" "github.com/jesseduffield/generics/set"
"github.com/jesseduffield/go-git/v5/config" "github.com/jesseduffield/go-git/v5/config"
@ -14,6 +15,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo" "github.com/samber/lo"
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
"golang.org/x/sync/errgroup"
) )
// context: // context:
@ -63,7 +65,13 @@ func NewBranchLoader(
} }
// Load the list of branches for the current repo // 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)) branches := self.obtainBranches(self.version.IsAtLeast(2, 22, 0))
if self.AppState.LocalBranchSortOrder == "recency" { if self.AppState.LocalBranchSortOrder == "recency" {
@ -122,11 +130,108 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit) ([]*models.Branch
branch.UpstreamRemote = match.Remote branch.UpstreamRemote = match.Remote
branch.UpstreamBranch = match.Merge.Short() 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 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)
//
// 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 { func (self *BranchLoader) obtainBranches(canUsePushTrack bool) []*models.Branch {
output, err := self.getRawBranches() output, err := self.getRawBranches()
if err != nil { if err != nil {

View File

@ -35,11 +35,6 @@ type CommitLoader struct {
readFile func(filename string) ([]byte, error) readFile func(filename string) ([]byte, error)
walkFiles func(root string, fn filepath.WalkFunc) error walkFiles func(root string, fn filepath.WalkFunc) error
dotGitDir string 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 *GitCommon
} }
@ -56,7 +51,6 @@ func NewCommitLoader(
getRebaseMode: getRebaseMode, getRebaseMode: getRebaseMode,
readFile: os.ReadFile, readFile: os.ReadFile,
walkFiles: filepath.Walk, walkFiles: filepath.Walk,
mainBranches: nil,
GitCommon: gitCommon, GitCommon: gitCommon,
} }
} }
@ -72,6 +66,7 @@ type GetCommitsOptions struct {
All bool All bool
// If non-empty, show divergence from this ref (left-right log) // If non-empty, show divergence from this ref (left-right log)
RefToShowDivergenceFrom string RefToShowDivergenceFrom string
MainBranches *MainBranches
} }
// GetCommits obtains the commits of the current branch // GetCommits obtains the commits of the current branch
@ -108,9 +103,9 @@ func (self *CommitLoader) GetCommits(opts GetCommitsOptions) ([]*models.Commit,
go utils.Safe(func() { go utils.Safe(func() {
defer wg.Done() defer wg.Done()
ancestor = self.getMergeBase(opts.RefName) ancestor = opts.MainBranches.GetMergeBase(opts.RefName)
if opts.RefToShowDivergenceFrom != "" { 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 { func ignoringWarnings(commandOutput string) string {
trimmedOutput := strings.TrimSpace(commandOutput) trimmedOutput := strings.TrimSpace(commandOutput)
split := strings.Split(trimmedOutput, "\n") split := strings.Split(trimmedOutput, "\n")

View File

@ -307,10 +307,11 @@ func TestGetCommits(t *testing.T) {
common := utils.NewDummyCommon() common := utils.NewDummyCommon()
common.AppState = &config.AppState{} common.AppState = &config.AppState{}
common.AppState.GitLogOrder = scenario.logOrder common.AppState.GitLogOrder = scenario.logOrder
cmd := oscommands.NewDummyCmdObjBuilder(scenario.runner)
builder := &CommitLoader{ builder := &CommitLoader{
Common: common, Common: common,
cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner), cmd: cmd,
getRebaseMode: func() (enums.RebaseMode, error) { return scenario.rebaseMode, nil }, getRebaseMode: func() (enums.RebaseMode, error) { return scenario.rebaseMode, nil },
dotGitDir: ".git", dotGitDir: ".git",
readFile: func(filename string) ([]byte, error) { readFile: func(filename string) ([]byte, error) {
@ -322,7 +323,9 @@ func TestGetCommits(t *testing.T) {
} }
common.UserConfig.Git.MainBranches = scenario.mainBranches 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.expectedCommits, commits)
assert.Equal(t, scenario.expectedError, err) assert.Equal(t, scenario.expectedError, err)

View File

@ -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
}

View File

@ -1,6 +1,9 @@
package models package models
import "fmt" import (
"fmt"
"sync/atomic"
)
// Branch : A git branch // Branch : A git branch
// duplicating this for now // duplicating this for now
@ -32,6 +35,11 @@ type Branch struct {
Subject string Subject string
// commit hash // commit hash
CommitHash string 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 { func (b *Branch) FullRefName() string {

View File

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

View File

@ -7,7 +7,12 @@ import (
) )
func (config *UserConfig) Validate() error { 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 err
} }
return nil return nil

View File

@ -130,7 +130,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) error {
if self.c.AppState.LocalBranchSortOrder == "recency" { if self.c.AppState.LocalBranchSortOrder == "recency" {
refresh("reflog and branches", func() { self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex) }) refresh("reflog and branches", func() { self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex) })
} else { } else {
refresh("branches", func() { self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex) }) refresh("branches", func() { self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true) })
refresh("reflog", func() { _ = self.refreshReflogCommits() }) refresh("reflog", func() { _ = self.refreshReflogCommits() })
} }
} else if scopeSet.Includes(types.REBASE_COMMITS) { } else if scopeSet.Includes(types.REBASE_COMMITS) {
@ -256,7 +256,7 @@ func (self *RefreshHelper) refreshReflogCommitsConsideringStartup() {
case types.INITIAL: case types.INITIAL:
self.c.OnWorker(func(_ gocui.Task) error { self.c.OnWorker(func(_ gocui.Task) error {
_ = self.refreshReflogCommits() _ = self.refreshReflogCommits()
self.refreshBranches(false, true) self.refreshBranches(false, true, true)
self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE)
return nil return nil
}) })
@ -267,9 +267,11 @@ func (self *RefreshHelper) refreshReflogCommitsConsideringStartup() {
} }
func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) { func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) {
loadBehindCounts := self.c.State().GetRepoState().GetStartupStage() == types.COMPLETE
self.refreshReflogCommitsConsideringStartup() self.refreshReflogCommitsConsideringStartup()
self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex) self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, loadBehindCounts)
} }
func (self *RefreshHelper) refreshCommitsAndCommitFiles() { func (self *RefreshHelper) refreshCommitsAndCommitFiles() {
@ -331,6 +333,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit() error {
RefName: self.refForLog(), RefName: self.refForLog(),
RefForPushedStatus: checkedOutBranchName, RefForPushedStatus: checkedOutBranchName,
All: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), All: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(),
MainBranches: self.c.Model().MainBranches,
}, },
) )
if err != nil { if err != nil {
@ -357,6 +360,7 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit() error {
RefName: self.c.Contexts().SubCommits.GetRef().FullRefName(), RefName: self.c.Contexts().SubCommits.GetRef().FullRefName(),
RefToShowDivergenceFrom: self.c.Contexts().SubCommits.GetRefToShowDivergenceFrom(), RefToShowDivergenceFrom: self.c.Contexts().SubCommits.GetRefToShowDivergenceFrom(),
RefForPushedStatus: self.c.Contexts().SubCommits.GetRef().FullRefName(), RefForPushedStatus: self.c.Contexts().SubCommits.GetRef().FullRefName(),
MainBranches: self.c.Model().MainBranches,
}, },
) )
if err != nil { 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 // 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 // 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() self.c.Mutexes().RefreshingBranchesMutex.Lock()
defer self.c.Mutexes().RefreshingBranchesMutex.Unlock() 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 { if err != nil {
self.c.Log.Error(err) self.c.Log.Error(err)
} }

View File

@ -44,6 +44,7 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error {
RefName: opts.Ref.FullRefName(), RefName: opts.Ref.FullRefName(),
RefForPushedStatus: opts.Ref.FullRefName(), RefForPushedStatus: opts.Ref.FullRefName(),
RefToShowDivergenceFrom: opts.RefToShowDivergenceFrom, RefToShowDivergenceFrom: opts.RefToShowDivergenceFrom,
MainBranches: self.c.Model().MainBranches,
}, },
) )
if err != nil { if err != nil {

View File

@ -12,6 +12,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo" "github.com/samber/lo"
) )
@ -116,7 +117,7 @@ func (self *StatusController) onClick(opts gocui.ViewMouseBindingOpts) error {
return err 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() repoName := self.c.Git().RepoPaths.RepoName()
workingTreeState := self.c.Git().Status.WorkingTreeState() workingTreeState := self.c.Git().Status.WorkingTreeState()
switch workingTreeState { switch workingTreeState {

View File

@ -379,6 +379,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
BisectInfo: git_commands.NewNullBisectInfo(), BisectInfo: git_commands.NewNullBisectInfo(),
FilesTrie: patricia.NewTrie(), FilesTrie: patricia.NewTrie(),
Authors: map[string]*models.Author{}, Authors: map[string]*models.Author{},
MainBranches: git_commands.NewMainBranches(gui.UserConfig.Git.MainBranches, gui.os.Cmd),
}, },
Modes: &types.Modes{ Modes: &types.Modes{
Filtering: filtering.New(startArgs.FilterPath, ""), Filtering: filtering.New(startArgs.FilterPath, ""),

View File

@ -56,7 +56,7 @@ func getBranchDisplayStrings(
// Recency is always three characters, plus one for the space // Recency is always three characters, plus one for the space
availableWidth := viewWidth - 4 availableWidth := viewWidth - 4
if len(branchStatus) > 0 { if len(branchStatus) > 0 {
availableWidth -= runewidth.StringWidth(branchStatus) + 1 availableWidth -= runewidth.StringWidth(utils.Decolorise(branchStatus)) + 1
} }
if icons.IsIconEnabled() { if icons.IsIconEnabled() {
availableWidth -= 2 // one for the icon, one for the space 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)) coloredName = fmt.Sprintf("%s %s", coloredName, style.FgDefault.Sprint(worktreeIcon))
} }
if len(branchStatus) > 0 { if len(branchStatus) > 0 {
coloredStatus := branchStatusColor(b, itemOperation).Sprint(branchStatus) coloredName = fmt.Sprintf("%s %s", coloredName, branchStatus)
coloredName = fmt.Sprintf("%s %s", coloredName, coloredStatus)
} }
recencyColor := style.FgCyan 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( func BranchStatus(
branch *models.Branch, branch *models.Branch,
itemOperation types.ItemOperation, itemOperation types.ItemOperation,
@ -177,30 +152,38 @@ func BranchStatus(
) string { ) string {
itemOperationStr := ItemOperationToString(itemOperation, tr) itemOperationStr := ItemOperationToString(itemOperation, tr)
if itemOperationStr != "" { if itemOperationStr != "" {
return itemOperationStr + " " + utils.Loader(now, userConfig.Gui.Spinner) return style.FgCyan.Sprintf("%s %s", 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 "?"
} }
result := "" result := ""
if branch.IsAheadForPull() { if branch.IsTrackingRemote() {
result = fmt.Sprintf("↑%s", branch.AheadForPull) 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.IsBehindForPull() {
result = fmt.Sprintf("%s↓%s", result, branch.BehindForPull) 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 return result

View File

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

View File

@ -2,6 +2,7 @@ package presentation
import ( import (
"fmt" "fmt"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/types/enums" "github.com/jesseduffield/lazygit/pkg/commands/types/enums"
@ -24,7 +25,10 @@ func FormatStatus(
status := "" status := ""
if currentBranch.IsRealBranch() { 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 { if workingTreeState != enums.REBASE_MODE_NONE {
@ -40,7 +44,7 @@ func FormatStatus(
} }
repoName = fmt.Sprintf("%s(%s%s)", repoName, icon, style.FgCyan.Sprint(linkedWorktreeName)) 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 return status
} }

View File

@ -281,6 +281,8 @@ type Model struct {
// we're on a detached head because we're rebasing or bisecting. // we're on a detached head because we're rebasing or bisecting.
CheckedOutBranch string CheckedOutBranch string
MainBranches *git_commands.MainBranches
// for displaying suggestions while typing in a file name // for displaying suggestions while typing in a file name
FilesTrie *patricia.Trie FilesTrie *patricia.Trie

View File

@ -44,7 +44,7 @@ var DeleteRemoteBranchWithCredentialPrompt = NewIntegrationTest(NewIntegrationTe
Confirm() Confirm()
} }
t.Views().Status().Content(Contains("✓ repo → mybranch")) t.Views().Status().Content(Equals("✓ repo → mybranch"))
deleteBranch() deleteBranch()
@ -66,7 +66,7 @@ var DeleteRemoteBranchWithCredentialPrompt = NewIntegrationTest(NewIntegrationTe
Content(Contains("incorrect username/password")). Content(Contains("incorrect username/password")).
Confirm() Confirm()
t.Views().Status().Content(Contains("✓ repo → mybranch")) t.Views().Status().Content(Equals("✓ repo → mybranch"))
// try again with correct password // try again with correct password
deleteBranch() deleteBranch()
@ -81,7 +81,7 @@ var DeleteRemoteBranchWithCredentialPrompt = NewIntegrationTest(NewIntegrationTe
Type("password"). Type("password").
Confirm() 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)")) t.Views().Branches().TopLines(Contains("mybranch (upstream gone)"))
}, },
}) })

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

@ -26,7 +26,7 @@ var ForcePush = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), 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) t.Views().Files().IsFocused().Press(keys.Universal.Push)
@ -40,7 +40,7 @@ var ForcePush = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("✓ repo → master")) t.Views().Status().Content(Equals("✓ repo → master"))
t.Views().Remotes().Focus(). t.Views().Remotes().Focus().
Lines(Contains("origin")). Lines(Contains("origin")).

View File

@ -22,7 +22,7 @@ var ForcePushMultipleMatching = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("↓1 repo → master")) t.Views().Status().Content(Equals("↓1 repo → master"))
t.Views().Branches(). t.Views().Branches().
Lines( Lines(
@ -42,7 +42,7 @@ var ForcePushMultipleMatching = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("✓ repo → master")) t.Views().Status().Content(Equals("✓ repo → master"))
t.Views().Branches(). t.Views().Branches().
Lines( Lines(

View File

@ -21,7 +21,7 @@ var ForcePushMultipleUpstream = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("↓1 repo → master")) t.Views().Status().Content(Equals("↓1 repo → master"))
t.Views().Branches(). t.Views().Branches().
Lines( Lines(
@ -41,7 +41,7 @@ var ForcePushMultipleUpstream = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("✓ repo → master")) t.Views().Status().Content(Equals("✓ repo → master"))
t.Views().Branches(). t.Views().Branches().
Lines( Lines(

View File

@ -26,7 +26,7 @@ var Pull = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), 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) t.Views().Files().IsFocused().Press(keys.Universal.Pull)
@ -36,6 +36,6 @@ var Pull = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("✓ repo → master")) t.Views().Status().Content(Equals("✓ repo → master"))
}, },
}) })

View File

@ -25,7 +25,7 @@ var PullAndSetUpstream = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("repo → master")) t.Views().Status().Content(Equals("repo → master"))
t.Views().Files().IsFocused().Press(keys.Universal.Pull) t.Views().Files().IsFocused().Press(keys.Universal.Pull)
@ -40,6 +40,6 @@ var PullAndSetUpstream = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("✓ repo → master")) t.Views().Status().Content(Equals("✓ repo → master"))
}, },
}) })

View File

@ -33,13 +33,13 @@ var PullMerge = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("↓2 repo → master")) t.Views().Status().Content(Equals("↓2↑1 repo → master"))
t.Views().Files(). t.Views().Files().
IsFocused(). IsFocused().
Press(keys.Universal.Pull) Press(keys.Universal.Pull)
t.Views().Status().Content(Contains("↑2 repo → master")) t.Views().Status().Content(Equals("↑2 repo → master"))
t.Views().Commits(). t.Views().Commits().
Lines( Lines(

View File

@ -34,7 +34,7 @@ var PullMergeConflict = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("↓2 repo → master")) t.Views().Status().Content(Equals("↓2↑1 repo → master"))
t.Views().Files(). t.Views().Files().
IsFocused(). IsFocused().
@ -62,7 +62,7 @@ var PullMergeConflict = NewIntegrationTest(NewIntegrationTestArgs{
t.Common().ContinueOnConflictsResolved() t.Common().ContinueOnConflictsResolved()
t.Views().Status().Content(Contains("↑2 repo → master")) t.Views().Status().Content(Equals("↑2 repo → master"))
t.Views().Commits(). t.Views().Commits().
Focus(). Focus().

View File

@ -35,13 +35,13 @@ var PullRebase = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("↓2 repo → master")) t.Views().Status().Content(Equals("↓2↑1 repo → master"))
t.Views().Files(). t.Views().Files().
IsFocused(). IsFocused().
Press(keys.Universal.Pull) Press(keys.Universal.Pull)
t.Views().Status().Content(Contains("↑1 repo → master")) t.Views().Status().Content(Equals("↑1 repo → master"))
t.Views().Commits(). t.Views().Commits().
Lines( Lines(

View File

@ -34,7 +34,7 @@ var PullRebaseConflict = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("↓2 repo → master")) t.Views().Status().Content(Equals("↓2↑1 repo → master"))
t.Views().Files(). t.Views().Files().
IsFocused(). IsFocused().
@ -63,7 +63,7 @@ var PullRebaseConflict = NewIntegrationTest(NewIntegrationTestArgs{
t.Common().ContinueOnConflictsResolved() t.Common().ContinueOnConflictsResolved()
t.Views().Status().Content(Contains("↑1 repo → master")) t.Views().Status().Content(Equals("↑1 repo → master"))
t.Views().Commits(). t.Views().Commits().
Focus(). Focus().

View File

@ -38,7 +38,7 @@ var PullRebaseInteractiveConflict = NewIntegrationTest(NewIntegrationTestArgs{
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("↓2 repo → master")) t.Views().Status().Content(Equals("↓2↑2 repo → master"))
t.Views().Files(). t.Views().Files().
IsFocused(). IsFocused().
@ -76,7 +76,7 @@ var PullRebaseInteractiveConflict = NewIntegrationTest(NewIntegrationTestArgs{
t.Common().ContinueOnConflictsResolved() t.Common().ContinueOnConflictsResolved()
t.Views().Status().Content(Contains("↑2 repo → master")) t.Views().Status().Content(Equals("↑2 repo → master"))
t.Views().Commits(). t.Views().Commits().
Focus(). Focus().

View File

@ -38,7 +38,7 @@ var PullRebaseInteractiveConflictDrop = NewIntegrationTest(NewIntegrationTestArg
Contains("one"), Contains("one"),
) )
t.Views().Status().Content(Contains("↓2 repo → master")) t.Views().Status().Content(Equals("↓2↑2 repo → master"))
t.Views().Files(). t.Views().Files().
IsFocused(). IsFocused().
@ -85,7 +85,7 @@ var PullRebaseInteractiveConflictDrop = NewIntegrationTest(NewIntegrationTestArg
t.Common().ContinueOnConflictsResolved() t.Common().ContinueOnConflictsResolved()
t.Views().Status().Content(Contains("↑1 repo → master")) t.Views().Status().Content(Equals("↑1 repo → master"))
t.Views().Commits(). t.Views().Commits().
Focus(). Focus().

View File

@ -21,7 +21,7 @@ var Push = NewIntegrationTest(NewIntegrationTestArgs{
shell.EmptyCommit("two") shell.EmptyCommit("two")
}, },
Run: func(t *TestDriver, keys config.KeybindingConfig) { 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(). t.Views().Files().
IsFocused(). IsFocused().

View File

@ -22,7 +22,7 @@ var PushAndAutoSetUpstream = NewIntegrationTest(NewIntegrationTestArgs{
}, },
Run: func(t *TestDriver, keys config.KeybindingConfig) { Run: func(t *TestDriver, keys config.KeybindingConfig) {
// assert no mention of upstream/downstream changes // 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(). t.Views().Files().
IsFocused(). IsFocused().

View File

@ -19,7 +19,7 @@ var PushAndSetUpstream = NewIntegrationTest(NewIntegrationTestArgs{
}, },
Run: func(t *TestDriver, keys config.KeybindingConfig) { Run: func(t *TestDriver, keys config.KeybindingConfig) {
// assert no mention of upstream/downstream changes // 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(). t.Views().Files().
IsFocused(). IsFocused().

View File

@ -24,13 +24,13 @@ var PushFollowTags = NewIntegrationTest(NewIntegrationTestArgs{
shell.SetConfig("push.followTags", "true") shell.SetConfig("push.followTags", "true")
}, },
Run: func(t *TestDriver, keys config.KeybindingConfig) { 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(). t.Views().Files().
IsFocused(). IsFocused().
Press(keys.Universal.Push) Press(keys.Universal.Push)
t.Views().Status().Content(Contains("✓ repo → master")) t.Views().Status().Content(Equals("✓ repo → master"))
t.Views().Remotes(). t.Views().Remotes().
Focus(). Focus().

View File

@ -22,13 +22,13 @@ var PushNoFollowTags = NewIntegrationTest(NewIntegrationTestArgs{
shell.CreateAnnotatedTag("mytag", "message", "HEAD") shell.CreateAnnotatedTag("mytag", "message", "HEAD")
}, },
Run: func(t *TestDriver, keys config.KeybindingConfig) { Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Status().Content(Contains("✓ repo → master")) t.Views().Status().Content(Equals("✓ repo → master"))
t.Views().Files(). t.Views().Files().
IsFocused(). IsFocused().
Press(keys.Universal.Push) Press(keys.Universal.Push)
t.Views().Status().Content(Contains("✓ repo → master")) t.Views().Status().Content(Equals("✓ repo → master"))
t.Views().Remotes(). t.Views().Remotes().
Focus(). Focus().

View File

@ -26,7 +26,7 @@ var PushWithCredentialPrompt = NewIntegrationTest(NewIntegrationTestArgs{
shell.CopyHelpFile("pre-push", ".git/hooks/pre-push") shell.CopyHelpFile("pre-push", ".git/hooks/pre-push")
}, },
Run: func(t *TestDriver, keys config.KeybindingConfig) { 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(). t.Views().Files().
IsFocused(). IsFocused().
@ -50,7 +50,7 @@ var PushWithCredentialPrompt = NewIntegrationTest(NewIntegrationTestArgs{
Content(Contains("incorrect username/password")). Content(Contains("incorrect username/password")).
Confirm() Confirm()
t.Views().Status().Content(Contains("↑1 repo → master")) t.Views().Status().Content(Equals("↑1 repo → master"))
// try again with correct password // try again with correct password
t.Views().Files(). t.Views().Files().
@ -67,7 +67,7 @@ var PushWithCredentialPrompt = NewIntegrationTest(NewIntegrationTestArgs{
Type("password"). Type("password").
Confirm() Confirm()
t.Views().Status().Content(Contains("✓ repo → master")) t.Views().Status().Content(Equals("✓ repo → master"))
assertSuccessfullyPushed(t) assertSuccessfullyPushed(t)
}, },

View File

@ -24,7 +24,7 @@ func createTwoBranchesReadyToForcePush(shell *Shell) {
} }
func assertSuccessfullyPushed(t *TestDriver) { func assertSuccessfullyPushed(t *TestDriver) {
t.Views().Status().Content(Contains("✓ repo → master")) t.Views().Status().Content(Equals("✓ repo → master"))
t.Views().Remotes(). t.Views().Remotes().
Focus(). Focus().

View File

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

View File

@ -331,6 +331,16 @@
"description": "If true, show commit hashes alongside branch names in the branches view.", "description": "If true, show commit hashes alongside branch names in the branches view.",
"default": false "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": { "commandLogSize": {
"type": "integer", "type": "integer",
"minimum": 0, "minimum": 0,