diff --git a/docs-master/Config.md b/docs-master/Config.md index 882baec638b..138d63d88a5 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -391,7 +391,9 @@ git: squashMergeMessage: Squash merge {{selectedRef}} into {{currentBranch}} # list of branches that are considered 'main' branches, used when displaying - # commits + # commits and for determining each branch's base. Lazygit prompts when a branch + # could be based on more than one of these (typical when one is regularly merged + # into another). mainBranches: - master - main diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index b41b0564ff7..8e9e26c7c31 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -156,7 +156,7 @@ func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches( } if self.version.IsAtLeast(2, 41, 0) { - return self.getBehindBaseBranchValuesFast(branches, mainBranchRefs, renderFunc) + return self.getBehindBaseBranchValuesFast(branches, mainBranches, renderFunc) } return self.getBehindBaseBranchValuesLegacy(branches, mainBranches, renderFunc) } @@ -171,31 +171,11 @@ func (self *BranchLoader) getBehindBaseBranchValuesLegacy( for _, branch := range branches { errg.Go(func() error { - baseBranch, err := self.GetBaseBranch(branch, mainBranches) + _, behinds, err := self.baseBranchCandidatesAndBehinds(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)) + branch.BehindBaseBranch.Store(classifyBehind(behinds)) return nil }) } @@ -206,9 +186,13 @@ func (self *BranchLoader) getBehindBaseBranchValuesLegacy( return err } -// Holds parsed values from a single %(ahead-behind:) field. +// Holds parsed values from a single %(ahead-behind:) field. `valid` +// is false when the field failed to parse (e.g. the base was unreachable +// from this ref); the entry is preserved so that the slice stays index- +// aligned with the configured main branches. type aheadBehind struct { ahead, behind int + valid bool } type branchAheadBehind struct { @@ -222,7 +206,7 @@ type branchAheadBehind struct { // // Lines whose NUL-split column count doesn't match (1 + numBases) are dropped. // Blank lines are ignored. -// Individual malformed ahead-behind fields produce {valid: false} entries +// Individual malformed ahead-behind fields produce {valid: false} entries. func parseAheadBehindForEachRefOutput( output string, numBases int, // number of %(ahead-behind:...) tokens @@ -238,7 +222,7 @@ func parseAheadBehindForEachRefOutput( continue } refName := cols[0] - aheadBehinds := lo.FilterMap(cols[1:], func(col string, _ int) (aheadBehind, bool) { + aheadBehinds := lo.Map(cols[1:], func(col string, _ int) aheadBehind { return parseAheadBehindField(col) }) entry := branchAheadBehind{ @@ -250,27 +234,77 @@ func parseAheadBehindForEachRefOutput( return result } -func parseAheadBehindField(s string) (aheadBehind, bool) { +func parseAheadBehindField(s string) aheadBehind { parts := strings.Fields(s) if len(parts) != 2 { - return aheadBehind{}, false + return aheadBehind{} } ahead, err1 := strconv.Atoi(parts[0]) behind, err2 := strconv.Atoi(parts[1]) if err1 != nil || err2 != nil { - return aheadBehind{}, false + return aheadBehind{} + } + return aheadBehind{ahead: ahead, behind: behind, valid: true} +} + +// selectBaseForBranch picks the closest base(s) for a branch given +// (ahead, behind) measurements against each configured main branch. +// "Closest" = smallest ahead value (fewest branch commits not in the +// base). Ties are broken by the order of mainRefs (i.e. config order). +// +// aheadBehinds must be index-aligned with mainRefs; invalid entries are +// skipped. Returns parallel slices: the refs tied at the minimum ahead +// (in config order) and their behind values. The caller picks +// candidates[0] for a single answer, or detects ambiguity via +// `len(candidates) > 1`. +func selectBaseForBranch( + aheadBehinds []aheadBehind, mainRefs []string, +) (candidates []string, behinds []int) { + bestAhead := -1 + for i, ab := range aheadBehinds { + if !ab.valid { + continue + } + switch { + case bestAhead < 0 || ab.ahead < bestAhead: + bestAhead = ab.ahead + candidates = []string{mainRefs[i]} + behinds = []int{ab.behind} + case ab.ahead == bestAhead: + candidates = append(candidates, mainRefs[i]) + behinds = append(behinds, ab.behind) + } } - return aheadBehind{ahead: ahead, behind: behind}, true + return candidates, behinds } -// Picks the "closest" base by smallest ahead value (commits the branch -// has that the base doesn't = roughly "since fork point") and returns -// its behind value. -// Ties are broken by index order -func selectBehindForBranch(aheadBehinds []aheadBehind) int { - return lo.MinBy(aheadBehinds, func(a, b aheadBehind) bool { - return a.ahead < b.ahead - }).behind +// classifyBehind condenses per-candidate behind values into the single +// number stored on Branch.BehindBaseBranch for column display. When the +// candidates all agree (possibly on 0), return that value; otherwise +// return one of the BehindBaseAmbiguous* sentinels so the renderer can +// show "?" or "↓?" instead of a misleadingly precise count. +func classifyBehind(behinds []int) int32 { + if len(behinds) == 0 { + return 0 + } + first := behinds[0] + allEqual := true + anyZero := first == 0 + for _, b := range behinds[1:] { + if b != first { + allEqual = false + } + if b == 0 { + anyZero = true + } + } + if allEqual { + return int32(first) + } + if anyZero { + return models.BehindBaseAmbiguousMaybeUpToDate + } + return models.BehindBaseAmbiguousDefinitelyBehind } // The output format is: @@ -296,11 +330,12 @@ func buildAheadBehindForEachRefArgs(mainBranchRefs []string) []string { func (self *BranchLoader) getBehindBaseBranchValuesFast( branches []*models.Branch, - mainBranchRefs []string, + mainBranches *MainBranches, renderFunc func(), ) error { t := time.Now() + mainBranchRefs := mainBranches.Get() output, err := self.cmd.New( buildAheadBehindForEachRefArgs(mainBranchRefs), ).DontLog().RunWithOutput() @@ -313,8 +348,8 @@ func (self *BranchLoader) getBehindBaseBranchValuesFast( for _, p := range parsed { if branch, ok := branchByRef[p.refName]; ok { - behind := selectBehindForBranch(p.aheadBehinds) - branch.BehindBaseBranch.Store(int32(behind)) + _, behinds := selectBaseForBranch(p.aheadBehinds, mainBranchRefs) + branch.BehindBaseBranch.Store(classifyBehind(behinds)) delete(branchByRef, p.refName) } } @@ -329,37 +364,83 @@ func (self *BranchLoader) getBehindBaseBranchValuesFast( return nil } -// 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) { +// GetBaseBranchCandidates returns the configured main branches that are the +// closest base for the given branch — typically a single ref, but more +// when the closeness rule (smallest ahead value) leaves a tie. Candidates +// are returned in config order, so callers wanting one answer can use +// candidates[0] as the config-order tiebreak. An empty slice (with nil +// error) means no configured main branch contains the branch's merge-base. +func (self *BranchLoader) GetBaseBranchCandidates(branch *models.Branch, mainBranches *MainBranches) ([]string, error) { + candidates, _, err := self.baseBranchCandidatesAndBehinds(branch, mainBranches) + return candidates, err +} + +// baseBranchCandidatesAndBehinds is the full computation behind +// GetBaseBranchCandidates: it also reports the behind count for each +// returned candidate, which the legacy behind-base loader needs in order +// to classify the column display when the candidates disagree. Slices +// are parallel and in config order. +func (self *BranchLoader) baseBranchCandidatesAndBehinds(branch *models.Branch, mainBranches *MainBranches) ([]string, []int, error) { mergeBase := mainBranches.GetMergeBase(branch.FullRefName()) if mergeBase == "" { - return "", nil + return nil, nil, nil } + mainBranchRefs := mainBranches.Get() output, err := self.cmd.New( NewGitCmd("for-each-ref"). Arg("--contains"). Arg(mergeBase). Arg("--format=%(refname)"). - Arg(mainBranches.Get()...). + Arg(mainBranchRefs...). ToArgv(), ).DontLog().RunWithOutput() if err != nil { - return "", err + return nil, nil, err } trimmedOutput := strings.TrimSpace(output) - split := strings.Split(trimmedOutput, "\n") - if len(split) == 0 || split[0] == "" { - return "", nil + if trimmedOutput == "" { + return nil, nil, nil + } + contained := strings.Split(trimmedOutput, "\n") + + // for-each-ref sorts its output alphabetically by refname regardless of + // the order we passed the refs in. Restore the user's configured order so + // it can serve as the natural tiebreaker. + containing := lo.Filter(mainBranchRefs, func(ref string, _ int) bool { + return lo.Contains(contained, ref) + }) + if len(containing) == 0 { + return nil, nil, nil + } + + // Measure ahead/behind against each containing ref and hand off to + // selectBaseForBranch — the same selector the fast path uses — so + // both paths agree on the closeness rule and the config-order + // tiebreak. We do this even when there's only one containing ref, + // because the legacy column display still needs the behind value. + aheadBehinds := make([]aheadBehind, len(containing)) + for i, ref := range containing { + revListOutput, err := self.cmd.New( + NewGitCmd("rev-list"). + Arg("--left-right"). + Arg("--count"). + Arg(fmt.Sprintf("%s...%s", branch.FullRefName(), ref)). + ToArgv(), + ).DontLog().RunWithOutput() + if err != nil { + return nil, nil, err + } + aheadBehinds[i] = parseAheadBehindField(strings.TrimSpace(revListOutput)) + } + + candidates, behinds := selectBaseForBranch(aheadBehinds, containing) + if len(candidates) == 0 { + // Every rev-list output was malformed; fall back to config order + // with no reliable behinds. + return containing, nil, nil } - return split[0], nil + return candidates, behinds, nil } func (self *BranchLoader) obtainBranches() []*models.Branch { diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go index f20ce6186e0..b3a3ade40f1 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -142,7 +142,7 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { expected: []branchAheadBehind{ { refName: "refs/heads/feat", - aheadBehinds: []aheadBehind{{ahead: 2, behind: 5}}, + aheadBehinds: []aheadBehind{{ahead: 2, behind: 5, valid: true}}, }, }, }, @@ -155,15 +155,15 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { { refName: "refs/heads/feat", aheadBehinds: []aheadBehind{ - {ahead: 2, behind: 5}, - {ahead: 10, behind: 1}, + {ahead: 2, behind: 5, valid: true}, + {ahead: 10, behind: 1, valid: true}, }, }, { refName: "refs/heads/main", aheadBehinds: []aheadBehind{ - {ahead: 0, behind: 0}, - {ahead: 0, behind: 0}, + {ahead: 0, behind: 0, valid: true}, + {ahead: 0, behind: 0, valid: true}, }, }, }, @@ -176,7 +176,8 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { { refName: "refs/heads/feat", aheadBehinds: []aheadBehind{ - {ahead: 2, behind: 5}, + {valid: false}, + {ahead: 2, behind: 5, valid: true}, }, }, }, @@ -188,7 +189,7 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { expected: []branchAheadBehind{ { refName: "refs/heads/feat/foo-bar", - aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}}, + aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}}, }, }, }, @@ -199,7 +200,7 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { expected: []branchAheadBehind{ { refName: "refs/heads/feat", - aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}}, + aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}}, }, }, }, @@ -212,11 +213,11 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { expected: []branchAheadBehind{ { refName: "refs/heads/good", - aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}}, + aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}}, }, { refName: "refs/heads/also_good", - aheadBehinds: []aheadBehind{{ahead: 3, behind: 4}}, + aheadBehinds: []aheadBehind{{ahead: 3, behind: 4, valid: true}}, }, }, }, @@ -227,7 +228,7 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { expected: []branchAheadBehind{ { refName: "refs/heads/feat", - aheadBehinds: []aheadBehind{}, + aheadBehinds: []aheadBehind{{valid: false}}, }, }, }, @@ -247,26 +248,58 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { } } -func TestSelectBehindForBranch(t *testing.T) { +func TestClassifyBehind(t *testing.T) { type scenario struct { - testName string - aheadBehinds []aheadBehind - expected int + testName string + behinds []int + expected int32 + } + + scenarios := []scenario{ + {"empty", nil, 0}, + {"single zero", []int{0}, 0}, + {"single non-zero", []int{5}, 5}, + {"all equal zero", []int{0, 0, 0}, 0}, + {"all equal non-zero", []int{7, 7}, 7}, + {"mixed zero and non-zero → ?", []int{0, 5}, models.BehindBaseAmbiguousMaybeUpToDate}, + {"mixed zero and non-zero, reversed → ?", []int{5, 0}, models.BehindBaseAmbiguousMaybeUpToDate}, + {"all non-zero, different → ↓?", []int{3, 5}, models.BehindBaseAmbiguousDefinitelyBehind}, + {"all non-zero, different (three) → ↓?", []int{3, 5, 7}, models.BehindBaseAmbiguousDefinitelyBehind}, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + assert.Equal(t, s.expected, classifyBehind(s.behinds)) + }) + } +} + +func TestSelectBaseForBranch(t *testing.T) { + type scenario struct { + testName string + aheadBehinds []aheadBehind + mainRefs []string + expectedCandidates []string + expectedBehinds []int } scenarios := []scenario{ { - testName: "single base, valid value", - aheadBehinds: []aheadBehind{{ahead: 3, behind: 7}}, - expected: 7, + testName: "single base, valid value", + aheadBehinds: []aheadBehind{{ahead: 3, behind: 7, valid: true}}, + mainRefs: []string{"refs/heads/master"}, + expectedCandidates: []string{"refs/heads/master"}, + expectedBehinds: []int{7}, }, { testName: "multi-base, clear winner by ahead", aheadBehinds: []aheadBehind{ - {ahead: 50, behind: 10}, // master - {ahead: 5, behind: 2}, // develop ← smallest ahead + {ahead: 50, behind: 10, valid: true}, // master + {ahead: 5, behind: 2, valid: true}, // develop ← smallest ahead }, - expected: 2, + mainRefs: []string{"refs/heads/master", "refs/heads/develop"}, + expectedCandidates: []string{"refs/heads/develop"}, + expectedBehinds: []int{2}, }, { testName: "develop forked from master case (ancestor-of-each-other)", @@ -275,42 +308,60 @@ func TestSelectBehindForBranch(t *testing.T) { // ahead vs master = 5 + 50 = 55; behind vs master = 0 // ahead vs develop = 5; behind vs develop = 5 aheadBehinds: []aheadBehind{ - {ahead: 55, behind: 0}, // master - {ahead: 5, behind: 5}, // develop ← smallest ahead + {ahead: 55, behind: 0, valid: true}, // master + {ahead: 5, behind: 5, valid: true}, // develop ← smallest ahead }, - expected: 5, + mainRefs: []string{"refs/heads/master", "refs/heads/develop"}, + expectedCandidates: []string{"refs/heads/develop"}, + expectedBehinds: []int{5}, }, { - testName: "tie on ahead - first base wins (config order)", + testName: "tie on ahead - both candidates returned in config order", aheadBehinds: []aheadBehind{ - {ahead: 5, behind: 10}, // first - {ahead: 5, behind: 99}, // second, same ahead + {ahead: 5, behind: 10, valid: true}, // first + {ahead: 5, behind: 99, valid: true}, // second, same ahead + }, + mainRefs: []string{"refs/heads/main", "refs/heads/develop"}, + expectedCandidates: []string{ + "refs/heads/main", + "refs/heads/develop", }, - expected: 10, + expectedBehinds: []int{10, 99}, }, { testName: "first base invalid, second valid", aheadBehinds: []aheadBehind{ - {ahead: 3, behind: 8}, + {valid: false}, + {ahead: 3, behind: 8, valid: true}, }, - expected: 8, + mainRefs: []string{"refs/heads/master", "refs/heads/develop"}, + expectedCandidates: []string{"refs/heads/develop"}, + expectedBehinds: []int{8}, }, { - testName: "all invalid - returns 0", - aheadBehinds: []aheadBehind{}, - expected: 0, + testName: "all invalid - returns empty", + aheadBehinds: []aheadBehind{ + {valid: false}, + {valid: false}, + }, + mainRefs: []string{"refs/heads/master", "refs/heads/develop"}, + expectedCandidates: nil, + expectedBehinds: nil, }, { - testName: "empty - returns 0", - aheadBehinds: nil, - expected: 0, + testName: "empty - returns empty", + aheadBehinds: nil, + mainRefs: nil, + expectedCandidates: nil, + expectedBehinds: nil, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { - result := selectBehindForBranch(s.aheadBehinds) - assert.Equal(t, s.expected, result) + candidates, behinds := selectBaseForBranch(s.aheadBehinds, s.mainRefs) + assert.Equal(t, s.expectedCandidates, candidates) + assert.Equal(t, s.expectedBehinds, behinds) }) } } @@ -458,8 +509,8 @@ func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) { {Name: "feat-x"}, } - // In legacy path: per-branch GetBaseBranch (merge-base + for-each-ref --contains) - // then rev-list --left-right --count. + // In legacy path: per-branch GetBaseBranchCandidates (merge-base + + // for-each-ref --contains) then rev-list --left-right --count. runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"merge-base", "refs/heads/feat-x", "refs/heads/master"}, "abc123\n", nil). ExpectGitArgs([]string{"for-each-ref", "--contains", "abc123", "--format=%(refname)", "refs/heads/master"}, "refs/heads/master\n", nil). @@ -491,3 +542,98 @@ func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) { runner.CheckForMissingCalls() } + +// When the branch's merge-base is contained in more than one configured main +// branch and the ahead counts are equal, the candidate list must preserve +// the user's configured order rather than the alphabetical order of +// for-each-ref's output. +func TestGetBaseBranchCandidates_AmbiguousReturnsAllInConfigOrder(t *testing.T) { + mainBranchRefs := []string{"refs/heads/main", "refs/heads/develop"} + branch := &models.Branch{Name: "feat-x"} + + runner := oscommands.NewFakeRunner(t). + ExpectGitArgs( + []string{"merge-base", "refs/heads/feat-x", "refs/heads/main", "refs/heads/develop"}, + "abc123\n", nil). + ExpectGitArgs( + []string{ + "for-each-ref", "--contains", "abc123", "--format=%(refname)", + "refs/heads/main", "refs/heads/develop", + }, + "refs/heads/develop\nrefs/heads/main\n", nil). + ExpectGitArgs( + []string{"rev-list", "--left-right", "--count", "refs/heads/feat-x...refs/heads/main"}, + "5\t10\n", nil). + ExpectGitArgs( + []string{"rev-list", "--left-right", "--count", "refs/heads/feat-x...refs/heads/develop"}, + "5\t8\n", nil) + + gitCommon := buildGitCommon(commonDeps{runner: runner}) + + loader := &BranchLoader{ + Common: gitCommon.Common, + GitCommon: gitCommon, + cmd: gitCommon.cmd, + } + + mainBranches := &MainBranches{ + c: gitCommon.Common, + cmd: gitCommon.cmd, + existingMainBranches: mainBranchRefs, + previousMainBranches: gitCommon.Common.UserConfig().Git.MainBranches, + } + + candidates, err := loader.GetBaseBranchCandidates(branch, mainBranches) + assert.NoError(t, err) + assert.Equal(t, []string{"refs/heads/main", "refs/heads/develop"}, candidates) + + runner.CheckForMissingCalls() +} + +// When a configured main branch has a strictly smaller ahead count than any +// other (e.g. the branch was forked off `main` after main's last merge into +// `develop`, so `develop` doesn't yet contain the fork point's recent main +// history), that base wins outright regardless of config order, so only +// that one ref is returned. +func TestGetBaseBranchCandidates_UnambiguousReturnsSmallestAheadOnly(t *testing.T) { + mainBranchRefs := []string{"refs/heads/develop", "refs/heads/main"} + branch := &models.Branch{Name: "feat-x"} + + runner := oscommands.NewFakeRunner(t). + ExpectGitArgs( + []string{"merge-base", "refs/heads/feat-x", "refs/heads/develop", "refs/heads/main"}, + "abc123\n", nil). + ExpectGitArgs( + []string{ + "for-each-ref", "--contains", "abc123", "--format=%(refname)", + "refs/heads/develop", "refs/heads/main", + }, + "refs/heads/develop\nrefs/heads/main\n", nil). + ExpectGitArgs( + []string{"rev-list", "--left-right", "--count", "refs/heads/feat-x...refs/heads/develop"}, + "8\t3\n", nil). + ExpectGitArgs( + []string{"rev-list", "--left-right", "--count", "refs/heads/feat-x...refs/heads/main"}, + "5\t10\n", nil) + + gitCommon := buildGitCommon(commonDeps{runner: runner}) + + loader := &BranchLoader{ + Common: gitCommon.Common, + GitCommon: gitCommon, + cmd: gitCommon.cmd, + } + + mainBranches := &MainBranches{ + c: gitCommon.Common, + cmd: gitCommon.cmd, + existingMainBranches: mainBranchRefs, + previousMainBranches: gitCommon.Common.UserConfig().Git.MainBranches, + } + + candidates, err := loader.GetBaseBranchCandidates(branch, mainBranches) + assert.NoError(t, err) + assert.Equal(t, []string{"refs/heads/main"}, candidates) + + runner.CheckForMissingCalls() +} diff --git a/pkg/commands/models/branch.go b/pkg/commands/models/branch.go index 4dc48a88d8d..c8afa6de241 100644 --- a/pkg/commands/models/branch.go +++ b/pkg/commands/models/branch.go @@ -39,9 +39,26 @@ type Branch struct { // 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.) + // Negative values are sentinels for the ambiguous case where we can't + // pick a single base; see BehindBase* constants below. BehindBaseBranch atomic.Int32 } +// Sentinel values stored in Branch.BehindBaseBranch when the base branch +// is ambiguous (more than one configured main branch tied at the closest +// position) and the candidates disagree on the behind count. +const ( + // BehindBaseAmbiguousMaybeUpToDate means at least one candidate has + // the branch up to date and at least one has it behind; we don't + // know which. Rendered as "?". + BehindBaseAmbiguousMaybeUpToDate int32 = -1 + + // BehindBaseAmbiguousDefinitelyBehind means every candidate has the + // branch behind by a non-zero amount, but the amounts differ — so we + // know the branch is behind, just not by how much. Rendered as "↓?". + BehindBaseAmbiguousDefinitelyBehind int32 = -2 +) + func (b *Branch) FullRefName() string { if b.DetachedHead { return b.Name diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index 1109256a9d3..cdec617ff68 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -640,7 +640,7 @@ git: # The commit message to use for a squash merge commit. Can contain "{{selectedRef}}" and "{{currentBranch}}" placeholders. squashMergeMessage: Squash merge {{selectedRef}} into {{currentBranch}} - # list of branches that are considered 'main' branches, used when displaying commits + # list of branches that are considered 'main' branches, used when displaying commits and for determining each branch's base. Lazygit prompts when a branch could be based on more than one of these (typical when one is regularly merged into another). mainBranches: - master - main diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 0b2dcac0ce0..dba988e2e86 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -281,7 +281,7 @@ type GitConfig struct { Commit CommitConfig `yaml:"commit"` // Config relating to merging Merging MergingConfig `yaml:"merging"` - // list of branches that are considered 'main' branches, used when displaying commits + // list of branches that are considered 'main' branches, used when displaying commits and for determining each branch's base. Lazygit prompts when a branch could be based on more than one of these (typical when one is regularly merged into another). MainBranches []string `yaml:"mainBranches" jsonschema:"uniqueItems=true"` // Prefix to use when skipping hooks. E.g. if set to 'WIP', then pre-commit hooks will be skipped when the commit message starts with 'WIP' SkipHookPrefix string `yaml:"skipHookPrefix"` diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 51e240a5d55..d83d31a3c1c 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -25,8 +25,9 @@ func (gui *Gui) resetHelpersAndControllers() { helperCommon := gui.c recordDirectoryHelper := helpers.NewRecordDirectoryHelper(helperCommon) reposHelper := helpers.NewRecentReposHelper(helperCommon, recordDirectoryHelper, gui.onSwitchToNewRepo) - rebaseHelper := helpers.NewMergeAndRebaseHelper(helperCommon) - refsHelper := helpers.NewRefsHelper(helperCommon, rebaseHelper) + baseBranchHelper := helpers.NewBaseBranchHelper(helperCommon) + rebaseHelper := helpers.NewMergeAndRebaseHelper(helperCommon, baseBranchHelper) + refsHelper := helpers.NewRefsHelper(helperCommon, rebaseHelper, baseBranchHelper) suggestionsHelper := helpers.NewSuggestionsHelper(helperCommon) worktreeHelper := helpers.NewWorktreeHelper(helperCommon, reposHelper, refsHelper, suggestionsHelper) @@ -129,6 +130,7 @@ func (gui *Gui) resetHelpersAndControllers() { Search: searchHelper, Worktree: worktreeHelper, SubCommits: helpers.NewSubCommitsHelper(helperCommon, refreshHelper), + BaseBranch: helpers.NewBaseBranchHelper(helperCommon), } gui.CustomCommandsClient = custom_commands.NewClient( diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 24ef84d5406..b2454b2c4ee 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -285,18 +285,26 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc } var disabledReason *types.DisabledReason - baseBranch, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(selectedBranch, self.c.Model().MainBranches) + baseBranch, baseAmbiguous, baseCandidates, err := self.c.Helpers().BaseBranch.ResolveBaseBranch(selectedBranch) if err != nil { return err } - if baseBranch == "" { - baseBranch = self.c.Tr.CouldNotDetermineBaseBranch + baseBranchLabel := helpers.BaseBranchDisplayName(baseBranch) + switch { + case baseBranch == "": + baseBranchLabel = self.c.Tr.CouldNotDetermineBaseBranch disabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch} + case baseAmbiguous: + shortNames := lo.Map(baseCandidates, func(ref string, _ int) string { + return helpers.BaseBranchDisplayName(ref) + }) + baseBranchLabel = utils.ResolvePlaceholderString(self.c.Tr.PickBaseBranchLabel, + map[string]string{"candidates": strings.Join(shortNames, ", ")}, + ) } - shortBaseBranchName := helpers.ShortBranchName(baseBranch) label := utils.ResolvePlaceholderString( self.c.Tr.ViewDivergenceFromBaseBranch, - map[string]string{"baseBranch": shortBaseBranchName}, + map[string]string{"baseBranch": baseBranchLabel}, ) viewDivergenceFromBaseBranchItem := &types.MenuItem{ LabelColumns: []string{label}, @@ -306,14 +314,19 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc if branch == nil { return nil } - - return self.c.Helpers().SubCommits.ViewSubCommits(helpers.ViewSubCommitsOpts{ - Ref: branch, - TitleRef: fmt.Sprintf("%s <-> %s", branch.RefName(), shortBaseBranchName), - RefToShowDivergenceFrom: baseBranch, - Context: self.context(), - ShowBranchHeads: false, - }) + showDivergence := func(base string) error { + return self.c.Helpers().SubCommits.ViewSubCommits(helpers.ViewSubCommitsOpts{ + Ref: branch, + TitleRef: fmt.Sprintf("%s <-> %s", branch.RefName(), helpers.BaseBranchDisplayName(base)), + RefToShowDivergenceFrom: base, + Context: self.context(), + ShowBranchHeads: false, + }) + } + if baseAmbiguous { + return self.c.Helpers().BaseBranch.ShowPicker(branch, baseCandidates, showDivergence) + } + return showDivergence(baseBranch) }, DisabledReason: disabledReason, } diff --git a/pkg/gui/controllers/helpers/base_branch_helper.go b/pkg/gui/controllers/helpers/base_branch_helper.go new file mode 100644 index 00000000000..8db870e9601 --- /dev/null +++ b/pkg/gui/controllers/helpers/base_branch_helper.go @@ -0,0 +1,63 @@ +package helpers + +import ( + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" +) + +// BaseBranchHelper resolves the base branch for a given branch. The +// closeness rule (smallest ahead value) usually picks a single answer +// but can leave a tie when the branch's fork point is reachable from +// more than one main branch — in that case the helper surfaces the +// candidates so the caller can disambiguate. +type BaseBranchHelper struct { + c *HelperCommon +} + +func NewBaseBranchHelper(c *HelperCommon) *BaseBranchHelper { + return &BaseBranchHelper{c: c} +} + +// ResolveBaseBranch returns the base branch for the given branch, the +// full set of tied candidates (for any disambiguation UI), and whether +// the answer is genuinely ambiguous (more than one candidate tied at +// the closest position). +// +// An empty baseRef (with no error) means no configured main branch +// contains the branch — not an error condition. +func (self *BaseBranchHelper) ResolveBaseBranch(branch *models.Branch) (baseRef string, ambiguous bool, candidates []string, err error) { + mainBranches := self.c.Model().MainBranches + candidates, err = self.c.Git().Loaders.BranchLoader.GetBaseBranchCandidates(branch, mainBranches) + if err != nil { + return "", false, nil, err + } + if len(candidates) == 0 { + return "", false, nil, nil + } + return candidates[0], len(candidates) > 1, candidates, nil +} + +// ShowPicker presents a menu of candidate base branches and runs +// onPicked with the user's selection. Callers should only invoke this +// when ResolveBaseBranch reported ambiguous=true; for the +// single-candidate case there is nothing to pick. +func (self *BaseBranchHelper) ShowPicker( + branch *models.Branch, + candidates []string, + onPicked func(baseRef string) error, +) error { + items := lo.Map(candidates, func(ref string, _ int) *types.MenuItem { + return &types.MenuItem{ + Label: BaseBranchDisplayName(ref), + OnPress: func() error { return onPicked(ref) }, + } + }) + return self.c.Menu(types.CreateMenuOptions{ + Title: utils.ResolvePlaceholderString(self.c.Tr.PickBaseBranchTitle, + map[string]string{"branchName": branch.Name}), + Prompt: self.c.Tr.PickBaseBranchPrompt, + Items: items, + }) +} diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 8af447f79b2..1cc5b84301f 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -195,8 +195,30 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branc return nil } -func ShortBranchName(fullBranchName string) string { - return strings.TrimPrefix(strings.TrimPrefix(fullBranchName, "refs/heads/"), "refs/remotes/") +// BaseBranchDisplayName returns the user-facing name of a configured main +// branch from its resolved full ref: +// +// refs/heads/main → main +// refs/remotes/origin/main → main +// refs/remotes/origin/feat/x → feat/x +// +// For remote-tracking refs the remote name is dropped along with the prefix: +// the user configured plain "main" in mainBranches and shouldn't have to see +// whether lazygit ultimately resolved it to a local or remote ref. The remote +// is only meaningful internally, so this function is intended specifically for +// base-branch display — don't use it where the local/remote distinction +// matters. +func BaseBranchDisplayName(fullBranchName string) string { + if name, ok := strings.CutPrefix(fullBranchName, "refs/heads/"); ok { + return name + } + if name, ok := strings.CutPrefix(fullBranchName, "refs/remotes/"); ok { + if _, withoutRemote, found := strings.Cut(name, "/"); found { + return withoutRemote + } + return name + } + return fullBranchName } func (self *BranchesHelper) checkedOutByOtherWorktree(branch *models.Branch) bool { diff --git a/pkg/gui/controllers/helpers/helpers.go b/pkg/gui/controllers/helpers/helpers.go index 4c9c79f3d81..a72c89263b5 100644 --- a/pkg/gui/controllers/helpers/helpers.go +++ b/pkg/gui/controllers/helpers/helpers.go @@ -53,6 +53,7 @@ type Helpers struct { Search *SearchHelper Worktree *WorktreeHelper SubCommits *SubCommitsHelper + BaseBranch *BaseBranchHelper } func NewStubHelpers() *Helpers { @@ -90,5 +91,6 @@ func NewStubHelpers() *Helpers { Search: &SearchHelper{}, Worktree: &WorktreeHelper{}, SubCommits: &SubCommitsHelper{}, + BaseBranch: &BaseBranchHelper{}, } } diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index cd141c69770..a419aa6ac17 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -17,14 +17,17 @@ import ( ) type MergeAndRebaseHelper struct { - c *HelperCommon + c *HelperCommon + baseBranchHelper *BaseBranchHelper } func NewMergeAndRebaseHelper( c *HelperCommon, + baseBranchHelper *BaseBranchHelper, ) *MergeAndRebaseHelper { return &MergeAndRebaseHelper{ - c: c, + c: c, + baseBranchHelper: baseBranchHelper, } } @@ -270,13 +273,22 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { disabledReason = &types.DisabledReason{Text: self.c.Tr.CantRebaseOntoSelf} } - baseBranch, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(checkedOutBranch, self.c.Model().MainBranches) + baseBranch, baseAmbiguous, baseCandidates, err := self.baseBranchHelper.ResolveBaseBranch(checkedOutBranch) if err != nil { return err } - if baseBranch == "" { - baseBranch = self.c.Tr.CouldNotDetermineBaseBranch + baseBranchLabel := BaseBranchDisplayName(baseBranch) + switch { + case baseBranch == "": + baseBranchLabel = self.c.Tr.CouldNotDetermineBaseBranch baseBranchDisabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch} + case baseAmbiguous: + shortNames := lo.Map(baseCandidates, func(ref string, _ int) string { + return BaseBranchDisplayName(ref) + }) + baseBranchLabel = utils.ResolvePlaceholderString(self.c.Tr.PickBaseBranchLabel, + map[string]string{"candidates": strings.Join(shortNames, ", ")}, + ) } menuItems := []*types.MenuItem{ @@ -332,27 +344,33 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { }, { Label: utils.ResolvePlaceholderString(self.c.Tr.RebaseOntoBaseBranch, - map[string]string{"baseBranch": ShortBranchName(baseBranch)}, + map[string]string{"baseBranch": baseBranchLabel}, ), Keys: menuKey('b'), DisabledReason: baseBranchDisabledReason, Tooltip: self.c.Tr.RebaseOntoBaseBranchTooltip, OnPress: func() error { - self.c.LogAction(self.c.Tr.Actions.RebaseBranch) - return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error { - baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() - var err error - if baseCommit != "" { - err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(baseBranch, baseCommit) - } else { - err = self.c.Git().Rebase.RebaseBranch(baseBranch) - } - err = self.CheckMergeOrRebase(err) - if err == nil { - return self.ResetMarkedBaseCommit() - } - return err - }) + doRebase := func(base string) error { + self.c.LogAction(self.c.Tr.Actions.RebaseBranch) + return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error { + baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() + var err error + if baseCommit != "" { + err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(base, baseCommit) + } else { + err = self.c.Git().Rebase.RebaseBranch(base) + } + err = self.CheckMergeOrRebase(err) + if err == nil { + return self.ResetMarkedBaseCommit() + } + return err + }) + } + if baseAmbiguous { + return self.baseBranchHelper.ShowPicker(checkedOutBranch, baseCandidates, doRebase) + } + return doRebase(baseBranch) }, }, } diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index a3db043ef66..6d12fb31df1 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -18,16 +18,19 @@ import ( type RefsHelper struct { c *HelperCommon - rebaseHelper *MergeAndRebaseHelper + rebaseHelper *MergeAndRebaseHelper + baseBranchHelper *BaseBranchHelper } func NewRefsHelper( c *HelperCommon, rebaseHelper *MergeAndRebaseHelper, + baseBranchHelper *BaseBranchHelper, ) *RefsHelper { return &RefsHelper{ - c: c, - rebaseHelper: rebaseHelper, + c: c, + rebaseHelper: rebaseHelper, + baseBranchHelper: baseBranchHelper, } } @@ -419,7 +422,7 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest func (self *RefsHelper) MoveCommitsToNewBranch() error { currentBranch := self.c.Model().Branches[0] - baseBranchRef, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(currentBranch, self.c.Model().MainBranches) + baseBranchRef, baseAmbiguous, baseCandidates, err := self.baseBranchHelper.ResolveBaseBranch(currentBranch) if err != nil { return err } @@ -468,11 +471,19 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error { return nil } - shortBaseBranchName := ShortBranchName(baseBranchRef) + baseBranchLabel := BaseBranchDisplayName(baseBranchRef) + if baseAmbiguous { + shortNames := lo.Map(baseCandidates, func(ref string, _ int) string { + return BaseBranchDisplayName(ref) + }) + baseBranchLabel = utils.ResolvePlaceholderString(self.c.Tr.PickBaseBranchLabel, + map[string]string{"candidates": strings.Join(shortNames, ", ")}, + ) + } prompt := utils.ResolvePlaceholderString( self.c.Tr.MoveCommitsToNewBranchMenuPrompt, map[string]string{ - "baseBranchName": shortBaseBranchName, + "baseBranchName": baseBranchLabel, }, ) return self.c.Menu(types.CreateMenuOptions{ @@ -480,11 +491,17 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error { Prompt: prompt, Items: []*types.MenuItem{ { - Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, shortBaseBranchName), + Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, baseBranchLabel), OnPress: func() error { - return withNewBranchNamePrompt(shortBaseBranchName, func(newBranchName string) error { - return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, baseBranchRef) - }) + moveOff := func(base string) error { + return withNewBranchNamePrompt(BaseBranchDisplayName(base), func(newBranchName string) error { + return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, base) + }) + } + if baseAmbiguous { + return self.baseBranchHelper.ShowPicker(currentBranch, baseCandidates, moveOff) + } + return moveOff(baseBranchRef) }, }, { diff --git a/pkg/gui/presentation/branches.go b/pkg/gui/presentation/branches.go index 2e8ab01065a..d170f9e832d 100644 --- a/pkg/gui/presentation/branches.go +++ b/pkg/gui/presentation/branches.go @@ -253,12 +253,28 @@ func divergenceStr( result := "" if ItemOperationToString(itemOperation, tr) == "" && userConfig.Gui.ShowDivergenceFromBaseBranch != "none" { behind := branch.BehindBaseBranch.Load() - if behind != 0 { - if userConfig.Gui.ShowDivergenceFromBaseBranch == "arrowAndNumber" { + showNumber := userConfig.Gui.ShowDivergenceFromBaseBranch == "arrowAndNumber" + switch { + case behind > 0: + if showNumber { result += fmt.Sprintf("↓%d", behind) } else { result += "↓" } + case behind == models.BehindBaseAmbiguousMaybeUpToDate: + // We don't know whether the branch is up to date or behind, + // because the candidate bases disagree. "?" intentionally has + // no arrow — we'd be implying "behind" if it did. + result += "?" + case behind == models.BehindBaseAmbiguousDefinitelyBehind: + // Every candidate has the branch behind, but by different + // amounts — show that it's behind without committing to a + // specific count. + if showNumber { + result += "↓?" + } else { + result += "↓" + } } } diff --git a/pkg/gui/presentation/branches_test.go b/pkg/gui/presentation/branches_test.go index b2c19a9ea6b..86e25df3cef 100644 --- a/pkg/gui/presentation/branches_test.go +++ b/pkg/gui/presentation/branches_test.go @@ -284,6 +284,66 @@ func Test_getBranchDisplayStrings(t *testing.T) { showDivergenceCfg: "none", expected: []string{"1m", "", "branc… Pushing |"}, }, + // Ambiguous base, candidates disagree on whether branch is up to date + // → render "?" without an arrow (we don't know if it's behind). + { + branch: &models.Branch{ + Name: "branch_name", + Recency: "1m", + BehindBaseBranch: *makeAtomic(models.BehindBaseAmbiguousMaybeUpToDate), + }, + itemOperation: types.ItemOperationNone, + fullDescription: false, + viewWidth: 20, + useIcons: false, + checkedOutByWorktree: false, + showDivergenceCfg: "arrowAndNumber", + expected: []string{"1m", "", "branch_name ?"}, + }, + { + branch: &models.Branch{ + Name: "branch_name", + Recency: "1m", + BehindBaseBranch: *makeAtomic(models.BehindBaseAmbiguousMaybeUpToDate), + }, + itemOperation: types.ItemOperationNone, + fullDescription: false, + viewWidth: 20, + useIcons: false, + checkedOutByWorktree: false, + showDivergenceCfg: "onlyArrow", + expected: []string{"1m", "", "branch_name ?"}, + }, + // Ambiguous base, every candidate has branch behind by some non-zero + // amount → render "↓?" (arrowAndNumber) or "↓" (onlyArrow). + { + branch: &models.Branch{ + Name: "branch_name", + Recency: "1m", + BehindBaseBranch: *makeAtomic(models.BehindBaseAmbiguousDefinitelyBehind), + }, + itemOperation: types.ItemOperationNone, + fullDescription: false, + viewWidth: 20, + useIcons: false, + checkedOutByWorktree: false, + showDivergenceCfg: "arrowAndNumber", + expected: []string{"1m", "", "branch_name ↓?"}, + }, + { + branch: &models.Branch{ + Name: "branch_name", + Recency: "1m", + BehindBaseBranch: *makeAtomic(models.BehindBaseAmbiguousDefinitelyBehind), + }, + itemOperation: types.ItemOperationNone, + fullDescription: false, + viewWidth: 20, + useIcons: false, + checkedOutByWorktree: false, + showDivergenceCfg: "onlyArrow", + expected: []string{"1m", "", "branch_name ↓"}, + }, { branch: &models.Branch{Name: "abc", Recency: "1m"}, itemOperation: types.ItemOperationPushing, diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index ee5f4ceec15..e486c1973bb 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -560,6 +560,9 @@ type TranslationSet struct { ViewDivergenceFromUpstream string ViewDivergenceFromBaseBranch string CouldNotDetermineBaseBranch string + PickBaseBranchTitle string + PickBaseBranchPrompt string + PickBaseBranchLabel string DivergenceSectionHeaderLocal string DivergenceSectionHeaderRemote string ViewUpstreamResetOptions string @@ -1686,6 +1689,9 @@ func EnglishTranslationSet() *TranslationSet { ViewDivergenceFromUpstream: "View divergence from upstream", ViewDivergenceFromBaseBranch: "View divergence from base branch ({{.baseBranch}})", CouldNotDetermineBaseBranch: "Couldn't determine base branch", + PickBaseBranchTitle: "Pick a base branch for {{.branchName}}", + PickBaseBranchPrompt: "More than one configured main branch is a candidate for this branch's base. Pick which one to treat as its base.", + PickBaseBranchLabel: "pick: {{.candidates}}", DivergenceSectionHeaderLocal: "Local", DivergenceSectionHeaderRemote: "Remote", ViewUpstreamResetOptions: "Reset checked-out branch onto {{.upstream}}", diff --git a/pkg/integration/tests/branch/move_commits_to_new_branch_from_base_branch.go b/pkg/integration/tests/branch/move_commits_to_new_branch_from_base_branch.go index 0b6bd71aa64..8c53fa9ccb8 100644 --- a/pkg/integration/tests/branch/move_commits_to_new_branch_from_base_branch.go +++ b/pkg/integration/tests/branch/move_commits_to_new_branch_from_base_branch.go @@ -37,11 +37,11 @@ var MoveCommitsToNewBranchFromBaseBranch = NewIntegrationTest(NewIntegrationTest t.ExpectPopup().Menu(). Title(Equals("Move commits to new branch")). - Select(Contains("New branch from base branch (origin/master)")). + Select(Contains("New branch from base branch (master)")). Confirm() t.ExpectPopup().Prompt(). - Title(Equals("New branch name (branch is off of 'origin/master')")). + Title(Equals("New branch name (branch is off of 'master')")). Type("new branch"). Confirm() diff --git a/schema-master/config.json b/schema-master/config.json index 84fd2220e80..71e304957cf 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -337,7 +337,7 @@ }, "type": "array", "uniqueItems": true, - "description": "list of branches that are considered 'main' branches, used when displaying commits", + "description": "list of branches that are considered 'main' branches, used when displaying commits and for determining each branch's base. Lazygit prompts when a branch could be based on more than one of these (typical when one is regularly merged into another).", "default": [ "master", "main"