From d158d7b68387d15ff69a2372efbd9653fa02e52d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 20:27:27 +0200 Subject: [PATCH 01/17] Add test demonstrating wrong base branch on ambiguous merge-base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetBaseBranch was determining its candidates purely by "this main branch contains the merge-base" — and then returning the first one without further discrimination. That equivalence class is too loose: multiple main branches can contain the merge-base even when one is clearly closer to the feature branch than another. The new test exercises that case: with main and develop both containing the branch's merge-base, the current code returns whichever git for-each-ref happens to list first, ignoring how close each candidate actually is. --- .../git_commands/branch_loader_test.go | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go index f20ce6186e0..339bfe59f14 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -491,3 +491,48 @@ func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) { runner.CheckForMissingCalls() } + +// When the merge-base is contained in more than one configured main branch, +// git for-each-ref returns those refs sorted alphabetically by refname, +// regardless of the order we pass them in. The chosen base should respect +// the user's configured order ("main" first), not the alphabetical accident. +// +// Demonstrates the bug; the expected behavior is asserted in the next commit. +func TestGetBaseBranch_AmbiguousPicksAlphabeticalNotConfigOrder(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) + + 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, + } + + baseBranch, err := loader.GetBaseBranch(branch, mainBranches) + assert.NoError(t, err) + // Want: "refs/heads/main" (first in config order among the tied candidates). + // Have: "refs/heads/develop" (alphabetical first from for-each-ref). + assert.Equal(t, "refs/heads/develop", baseBranch) + + runner.CheckForMissingCalls() +} From 1ddf773c4db13490cbe49245b564505b3adc80cc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 20:31:57 +0200 Subject: [PATCH 02/17] Pick base branch by smallest ahead instead of relying on for-each-ref's order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetBaseBranch was treating "contains the merge-base" as the equivalence class for "is the closest base," which is too loose — multiple main branches can contain the merge-base when one is dramatically closer to the feature branch than another. The candidate it returned was then whichever ref git for-each-ref happened to list first. For example, a branch forked off "develop" can have its combined merge-base with [main, develop] land on a commit reachable from both (via develop's own branch-off from main). Both main and develop end up in the candidate set, even though by any reasonable measure of closeness the branch differs from develop by a small ahead count and from main by a much larger one. Discriminate within the candidate set using ahead values: for each configured main branch that contains the merge-base, compute the ahead count from branch to base, and pick the candidate with the smallest ahead — the closest base. When more than one candidate is tied at the minimum, return that tied set unchanged so callers can flag the case as genuinely ambiguous instead of silently collapsing it; subsequent commits build the disambiguation prompt on top. --- pkg/commands/git_commands/branch_loader.go | 54 ++++++++++++-- .../git_commands/branch_loader_test.go | 71 ++++++++++++++++--- 2 files changed, 110 insertions(+), 15 deletions(-) diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index b41b0564ff7..321141c693d 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -343,23 +343,69 @@ func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *Mai return "", 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 } trimmedOutput := strings.TrimSpace(output) - split := strings.Split(trimmedOutput, "\n") - if len(split) == 0 || split[0] == "" { + if trimmedOutput == "" { + return "", 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. + candidates := lo.Filter(mainBranchRefs, func(ref string, _ int) bool { + return lo.Contains(contained, ref) + }) + if len(candidates) == 0 { return "", nil } - return split[0], nil + if len(candidates) == 1 { + return candidates[0], nil + } + + // Multiple main branches contain the merge-base. Pick the "closest" by + // the same definition the fast path uses (smallest ahead value, i.e. + // fewest branch commits not in the base). Ties fall back to config + // order, which `candidates` already preserves. + bestIdx := 0 + bestAhead := -1 + for i, ref := range candidates { + 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 "", err + } + parts := strings.Fields(strings.TrimSpace(revListOutput)) + if len(parts) != 2 { + continue + } + ahead, err := strconv.Atoi(parts[0]) + if err != nil { + continue + } + if bestAhead < 0 || ahead < bestAhead { + bestAhead = ahead + bestIdx = i + } + } + + return candidates[bestIdx], 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 339bfe59f14..2a0da414edb 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -492,13 +492,11 @@ func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) { runner.CheckForMissingCalls() } -// When the merge-base is contained in more than one configured main branch, -// git for-each-ref returns those refs sorted alphabetically by refname, -// regardless of the order we pass them in. The chosen base should respect -// the user's configured order ("main" first), not the alphabetical accident. -// -// Demonstrates the bug; the expected behavior is asserted in the next commit. -func TestGetBaseBranch_AmbiguousPicksAlphabeticalNotConfigOrder(t *testing.T) { +// When the branch's merge-base is contained in more than one configured main +// branch and the ahead counts are equal, the chosen base must respect the +// user's configured order rather than the alphabetical order of +// for-each-ref's output. +func TestGetBaseBranch_AmbiguousFallsBackToConfigOrder(t *testing.T) { mainBranchRefs := []string{"refs/heads/main", "refs/heads/develop"} branch := &models.Branch{Name: "feat-x"} @@ -511,7 +509,60 @@ func TestGetBaseBranch_AmbiguousPicksAlphabeticalNotConfigOrder(t *testing.T) { "for-each-ref", "--contains", "abc123", "--format=%(refname)", "refs/heads/main", "refs/heads/develop", }, - "refs/heads/develop\nrefs/heads/main\n", nil) + "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, + } + + baseBranch, err := loader.GetBaseBranch(branch, mainBranches) + assert.NoError(t, err) + assert.Equal(t, "refs/heads/main", baseBranch) + + 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. +func TestGetBaseBranch_UnambiguousPicksSmallestAhead(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}) @@ -530,9 +581,7 @@ func TestGetBaseBranch_AmbiguousPicksAlphabeticalNotConfigOrder(t *testing.T) { baseBranch, err := loader.GetBaseBranch(branch, mainBranches) assert.NoError(t, err) - // Want: "refs/heads/main" (first in config order among the tied candidates). - // Have: "refs/heads/develop" (alphabetical first from for-each-ref). - assert.Equal(t, "refs/heads/develop", baseBranch) + assert.Equal(t, "refs/heads/main", baseBranch) runner.CheckForMissingCalls() } From 0be0db2b1beeb3c40f51c4039c1f5dbe96692d24 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 20:36:01 +0200 Subject: [PATCH 03/17] Reshape selection to expose the winning base ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fast path's selectBehindForBranch returned only the behind value of the closest base, throwing away which base actually won. We need that information so subsequent commits can present a disambiguation prompt when more than one main branch is the closest base. Rename to selectBaseForBranch and have it return (winner, behind, candidates), where candidates is the full set of refs tied at the minimum ahead (in config order) so callers can recognise ambiguity via len(candidates) > 1. To make that possible, parseAheadBehindForEachRefOutput now preserves malformed entries with a valid=false flag instead of silently dropping them — without this the slice would drift out of alignment with mainRefs and the index→ref mapping would be unreliable. --- pkg/commands/git_commands/branch_loader.go | 57 ++++++--- .../git_commands/branch_loader_test.go | 108 ++++++++++++------ 2 files changed, 112 insertions(+), 53 deletions(-) diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index 321141c693d..f5f96f1d37c 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -206,9 +206,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 +226,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 +242,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 +254,48 @@ 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}, true + return aheadBehind{ahead: ahead, behind: behind, valid: true} } -// 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 +// selectBaseForBranch picks the closest base 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 the winning ref, its behind value, and the full set +// of refs tied at the minimum ahead (in config order). The caller can +// detect ambiguity via `len(candidates) > 1`. With no valid entry the +// return is ("", 0, nil). +func selectBaseForBranch( + aheadBehinds []aheadBehind, mainRefs []string, +) (winner string, behind int, candidates []string) { + bestAhead := -1 + for i, ab := range aheadBehinds { + if !ab.valid { + continue + } + switch { + case bestAhead < 0 || ab.ahead < bestAhead: + bestAhead = ab.ahead + winner = mainRefs[i] + behind = ab.behind + candidates = []string{mainRefs[i]} + case ab.ahead == bestAhead: + candidates = append(candidates, mainRefs[i]) + } + } + return winner, behind, candidates } // The output format is: @@ -313,7 +338,7 @@ func (self *BranchLoader) getBehindBaseBranchValuesFast( for _, p := range parsed { if branch, ok := branchByRef[p.refName]; ok { - behind := selectBehindForBranch(p.aheadBehinds) + _, behind, _ := selectBaseForBranch(p.aheadBehinds, mainBranchRefs) branch.BehindBaseBranch.Store(int32(behind)) delete(branchByRef, p.refName) } diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go index 2a0da414edb..fe004adc9ba 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,35 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { } } -func TestSelectBehindForBranch(t *testing.T) { +func TestSelectBaseForBranch(t *testing.T) { type scenario struct { - testName string - aheadBehinds []aheadBehind - expected int + testName string + aheadBehinds []aheadBehind + mainRefs []string + expectedWinner string + expectedBehind int + expectedCandidates []string } 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"}, + expectedWinner: "refs/heads/master", + expectedBehind: 7, + expectedCandidates: []string{"refs/heads/master"}, }, { 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"}, + expectedWinner: "refs/heads/develop", + expectedBehind: 2, + expectedCandidates: []string{"refs/heads/develop"}, }, { testName: "develop forked from master case (ancestor-of-each-other)", @@ -275,42 +285,66 @@ 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"}, + expectedWinner: "refs/heads/develop", + expectedBehind: 5, + expectedCandidates: []string{"refs/heads/develop"}, }, { testName: "tie on ahead - first base wins (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"}, + expectedWinner: "refs/heads/main", + expectedBehind: 10, + expectedCandidates: []string{ + "refs/heads/main", + "refs/heads/develop", }, - expected: 10, }, { 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"}, + expectedWinner: "refs/heads/develop", + expectedBehind: 8, + expectedCandidates: []string{"refs/heads/develop"}, }, { - 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"}, + expectedWinner: "", + expectedBehind: 0, + expectedCandidates: nil, }, { - testName: "empty - returns 0", - aheadBehinds: nil, - expected: 0, + testName: "empty - returns empty", + aheadBehinds: nil, + mainRefs: nil, + expectedWinner: "", + expectedBehind: 0, + expectedCandidates: nil, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { - result := selectBehindForBranch(s.aheadBehinds) - assert.Equal(t, s.expected, result) + winner, behind, candidates := selectBaseForBranch(s.aheadBehinds, s.mainRefs) + assert.Equal(t, s.expectedWinner, winner) + assert.Equal(t, s.expectedBehind, behind) + assert.Equal(t, s.expectedCandidates, candidates) }) } } From c840880fdceb92267a7574cd0f571242c86abac0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 20:37:40 +0200 Subject: [PATCH 04/17] Share the multi-candidate selector between fast and legacy paths Have GetBaseBranch reuse selectBaseForBranch to disambiguate the multi-candidate case, so the fast path (for-each-ref %(ahead-behind)) and the legacy path (rev-list --left-right --count) agree on the same rule for which base is "closest" and the same config-order tiebreak when ahead values are equal. The bespoke loop in GetBaseBranch goes away. --- pkg/commands/git_commands/branch_loader.go | 31 +++++++++------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index f5f96f1d37c..31cbf108e93 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -399,12 +399,11 @@ func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *Mai return candidates[0], nil } - // Multiple main branches contain the merge-base. Pick the "closest" by - // the same definition the fast path uses (smallest ahead value, i.e. - // fewest branch commits not in the base). Ties fall back to config - // order, which `candidates` already preserves. - bestIdx := 0 - bestAhead := -1 + // Multiple main branches contain the merge-base. Measure ahead/behind + // against each 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. + aheadBehinds := make([]aheadBehind, len(candidates)) for i, ref := range candidates { revListOutput, err := self.cmd.New( NewGitCmd("rev-list"). @@ -416,21 +415,15 @@ func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *Mai if err != nil { return "", err } - parts := strings.Fields(strings.TrimSpace(revListOutput)) - if len(parts) != 2 { - continue - } - ahead, err := strconv.Atoi(parts[0]) - if err != nil { - continue - } - if bestAhead < 0 || ahead < bestAhead { - bestAhead = ahead - bestIdx = i - } + aheadBehinds[i] = parseAheadBehindField(strings.TrimSpace(revListOutput)) } - return candidates[bestIdx], nil + winner, _, _ := selectBaseForBranch(aheadBehinds, candidates) + if winner == "" { + // Every rev-list output was malformed; fall back to config order. + return candidates[0], nil + } + return winner, nil } func (self *BranchLoader) obtainBranches() []*models.Branch { From 54c892e26a11da4201e0a63703509e743437d267 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 20:41:42 +0200 Subject: [PATCH 05/17] Return all tied candidates from base-branch detection The four callers of GetBaseBranch all ultimately want a single ref, but the disambiguation prompt we're about to add needs to know when multiple main branches are genuinely tied at the closest position so it can ask the user. Change the function to return all min-ahead refs in config order and rename to GetBaseBranchCandidates; each caller now takes candidates[0] as a placeholder until the prompt wiring lands. --- pkg/commands/git_commands/branch_loader.go | 51 +++++++++---------- .../git_commands/branch_loader_test.go | 23 +++++---- pkg/gui/controllers/branches_controller.go | 6 ++- .../helpers/merge_and_rebase_helper.go | 6 ++- pkg/gui/controllers/helpers/refs_helper.go | 6 ++- 5 files changed, 52 insertions(+), 40 deletions(-) diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index 31cbf108e93..2a519cf2f2d 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -171,10 +171,14 @@ func (self *BranchLoader) getBehindBaseBranchValuesLegacy( for _, branch := range branches { errg.Go(func() error { - baseBranch, err := self.GetBaseBranch(branch, mainBranches) + candidates, err := self.GetBaseBranchCandidates(branch, mainBranches) if err != nil { return err } + baseBranch := "" + if len(candidates) > 0 { + baseBranch = candidates[0] + } behind := 0 // prime it in case something below fails if baseBranch != "" { output, err := self.cmd.New( @@ -354,18 +358,16 @@ 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) { mergeBase := mainBranches.GetMergeBase(branch.FullRefName()) if mergeBase == "" { - return "", nil + return nil, nil } mainBranchRefs := mainBranches.Get() @@ -378,33 +380,30 @@ func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *Mai ToArgv(), ).DontLog().RunWithOutput() if err != nil { - return "", err + return nil, err } trimmedOutput := strings.TrimSpace(output) if trimmedOutput == "" { - return "", nil + return 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. - candidates := lo.Filter(mainBranchRefs, func(ref string, _ int) bool { + containing := lo.Filter(mainBranchRefs, func(ref string, _ int) bool { return lo.Contains(contained, ref) }) - if len(candidates) == 0 { - return "", nil - } - if len(candidates) == 1 { - return candidates[0], nil + if len(containing) <= 1 { + return containing, nil } // Multiple main branches contain the merge-base. Measure ahead/behind // against each 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. - aheadBehinds := make([]aheadBehind, len(candidates)) - for i, ref := range candidates { + aheadBehinds := make([]aheadBehind, len(containing)) + for i, ref := range containing { revListOutput, err := self.cmd.New( NewGitCmd("rev-list"). Arg("--left-right"). @@ -413,17 +412,17 @@ func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *Mai ToArgv(), ).DontLog().RunWithOutput() if err != nil { - return "", err + return nil, err } aheadBehinds[i] = parseAheadBehindField(strings.TrimSpace(revListOutput)) } - winner, _, _ := selectBaseForBranch(aheadBehinds, candidates) - if winner == "" { + _, _, candidates := selectBaseForBranch(aheadBehinds, containing) + if len(candidates) == 0 { // Every rev-list output was malformed; fall back to config order. - return candidates[0], nil + return containing, nil } - return winner, nil + return candidates, 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 fe004adc9ba..07b6d15847d 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -492,8 +492,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). @@ -527,10 +527,10 @@ func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) { } // When the branch's merge-base is contained in more than one configured main -// branch and the ahead counts are equal, the chosen base must respect the -// user's configured order rather than the alphabetical order of +// 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 TestGetBaseBranch_AmbiguousFallsBackToConfigOrder(t *testing.T) { +func TestGetBaseBranchCandidates_AmbiguousReturnsAllInConfigOrder(t *testing.T) { mainBranchRefs := []string{"refs/heads/main", "refs/heads/develop"} branch := &models.Branch{Name: "feat-x"} @@ -566,9 +566,9 @@ func TestGetBaseBranch_AmbiguousFallsBackToConfigOrder(t *testing.T) { previousMainBranches: gitCommon.Common.UserConfig().Git.MainBranches, } - baseBranch, err := loader.GetBaseBranch(branch, mainBranches) + candidates, err := loader.GetBaseBranchCandidates(branch, mainBranches) assert.NoError(t, err) - assert.Equal(t, "refs/heads/main", baseBranch) + assert.Equal(t, []string{"refs/heads/main", "refs/heads/develop"}, candidates) runner.CheckForMissingCalls() } @@ -576,8 +576,9 @@ func TestGetBaseBranch_AmbiguousFallsBackToConfigOrder(t *testing.T) { // 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. -func TestGetBaseBranch_UnambiguousPicksSmallestAhead(t *testing.T) { +// 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"} @@ -613,9 +614,9 @@ func TestGetBaseBranch_UnambiguousPicksSmallestAhead(t *testing.T) { previousMainBranches: gitCommon.Common.UserConfig().Git.MainBranches, } - baseBranch, err := loader.GetBaseBranch(branch, mainBranches) + candidates, err := loader.GetBaseBranchCandidates(branch, mainBranches) assert.NoError(t, err) - assert.Equal(t, "refs/heads/main", baseBranch) + assert.Equal(t, []string{"refs/heads/main"}, candidates) runner.CheckForMissingCalls() } diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 24ef84d5406..669e448bcbb 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -285,10 +285,14 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc } var disabledReason *types.DisabledReason - baseBranch, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(selectedBranch, self.c.Model().MainBranches) + candidates, err := self.c.Git().Loaders.BranchLoader.GetBaseBranchCandidates(selectedBranch, self.c.Model().MainBranches) if err != nil { return err } + baseBranch := "" + if len(candidates) > 0 { + baseBranch = candidates[0] + } if baseBranch == "" { baseBranch = self.c.Tr.CouldNotDetermineBaseBranch disabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch} diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index cd141c69770..cd4472c7427 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -270,10 +270,14 @@ 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) + candidates, err := self.c.Git().Loaders.BranchLoader.GetBaseBranchCandidates(checkedOutBranch, self.c.Model().MainBranches) if err != nil { return err } + baseBranch := "" + if len(candidates) > 0 { + baseBranch = candidates[0] + } if baseBranch == "" { baseBranch = self.c.Tr.CouldNotDetermineBaseBranch baseBranchDisabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch} diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index a3db043ef66..03b651d290a 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -419,10 +419,14 @@ 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) + candidates, err := self.c.Git().Loaders.BranchLoader.GetBaseBranchCandidates(currentBranch, self.c.Model().MainBranches) if err != nil { return err } + baseBranchRef := "" + if len(candidates) > 0 { + baseBranchRef = candidates[0] + } withNewBranchNamePrompt := func(baseBranchName string, f func(string) error) error { prompt := utils.ResolvePlaceholderString( From f770543ffa4f6eb9163ca271bdf81cf34714cee5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 21:04:49 +0200 Subject: [PATCH 06/17] Introduce BaseBranchHelper around candidate resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GUI call sites that need a base branch all share the same logic: ask GetBaseBranchCandidates, take candidates[0] as the config-order tiebreak, and surface the candidate list when the user needs to disambiguate. Extracting that into a helper keeps the upcoming prompt and rebase wiring focused on UX concerns. Not yet routed through — subsequent commits replace the direct GetBaseBranchCandidates calls with ResolveBaseBranch. --- pkg/gui/controllers.go | 1 + .../controllers/helpers/base_branch_helper.go | 37 +++++++++++++++++++ pkg/gui/controllers/helpers/helpers.go | 2 + 3 files changed, 40 insertions(+) create mode 100644 pkg/gui/controllers/helpers/base_branch_helper.go diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 51e240a5d55..7d065fac2b0 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -129,6 +129,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/helpers/base_branch_helper.go b/pkg/gui/controllers/helpers/base_branch_helper.go new file mode 100644 index 00000000000..e921ce93d65 --- /dev/null +++ b/pkg/gui/controllers/helpers/base_branch_helper.go @@ -0,0 +1,37 @@ +package helpers + +import ( + "github.com/jesseduffield/lazygit/pkg/commands/models" +) + +// 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 +} 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{}, } } From 6ff8a596e5b2126d4d7a9fa7fc403c17f26be0d5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 21:13:00 +0200 Subject: [PATCH 07/17] Route base-branch lookups through the shared resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four call sites that need a single base branch (legacy behind-base loader, view-divergence menu, rebase-onto-base action, move-commits-to-new-branch) now go through BaseBranchHelper.ResolveBaseBranch in the GUI sites, and directly take candidates[0] in the loader. They all use the same config-order tiebreak. No prompt yet — the helper still returns the config-order first for ambiguous cases; subsequent commits add the disambiguation menu. MergeAndRebaseHelper and RefsHelper take BaseBranchHelper at construction since helpers don't have access to Helpers() the way controllers do. --- pkg/gui/controllers.go | 5 +++-- pkg/gui/controllers/branches_controller.go | 6 +----- .../helpers/merge_and_rebase_helper.go | 13 ++++++------- pkg/gui/controllers/helpers/refs_helper.go | 15 +++++++-------- 4 files changed, 17 insertions(+), 22 deletions(-) diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 7d065fac2b0..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) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 669e448bcbb..133e57923fd 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -285,14 +285,10 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc } var disabledReason *types.DisabledReason - candidates, err := self.c.Git().Loaders.BranchLoader.GetBaseBranchCandidates(selectedBranch, self.c.Model().MainBranches) + baseBranch, _, _, err := self.c.Helpers().BaseBranch.ResolveBaseBranch(selectedBranch) if err != nil { return err } - baseBranch := "" - if len(candidates) > 0 { - baseBranch = candidates[0] - } if baseBranch == "" { baseBranch = self.c.Tr.CouldNotDetermineBaseBranch disabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch} diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index cd4472c7427..fa852bca845 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,14 +273,10 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { disabledReason = &types.DisabledReason{Text: self.c.Tr.CantRebaseOntoSelf} } - candidates, err := self.c.Git().Loaders.BranchLoader.GetBaseBranchCandidates(checkedOutBranch, self.c.Model().MainBranches) + baseBranch, _, _, err := self.baseBranchHelper.ResolveBaseBranch(checkedOutBranch) if err != nil { return err } - baseBranch := "" - if len(candidates) > 0 { - baseBranch = candidates[0] - } if baseBranch == "" { baseBranch = self.c.Tr.CouldNotDetermineBaseBranch baseBranchDisabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch} diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 03b651d290a..11def525551 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,14 +422,10 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest func (self *RefsHelper) MoveCommitsToNewBranch() error { currentBranch := self.c.Model().Branches[0] - candidates, err := self.c.Git().Loaders.BranchLoader.GetBaseBranchCandidates(currentBranch, self.c.Model().MainBranches) + baseBranchRef, _, _, err := self.baseBranchHelper.ResolveBaseBranch(currentBranch) if err != nil { return err } - baseBranchRef := "" - if len(candidates) > 0 { - baseBranchRef = candidates[0] - } withNewBranchNamePrompt := func(baseBranchName string, f func(string) error) error { prompt := utils.ResolvePlaceholderString( From ba926de581fe4aa5bdbe061f7213d0698717caaf Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 21:18:19 +0200 Subject: [PATCH 08/17] Add disambiguation menu to BaseBranchHelper When ResolveBaseBranch reports a tie, callers need a way to ask the user which of the configured main branches to use as the base. ShowPicker takes the candidate list and a continuation, presents a menu of short branch names, and runs the continuation with the user's selection. Subsequent commits wire this into the three GUI actions that care (rebase-onto-base, view-divergence, move-commits). --- .../controllers/helpers/base_branch_helper.go | 26 +++++++++++++++++++ pkg/i18n/english.go | 4 +++ 2 files changed, 30 insertions(+) diff --git a/pkg/gui/controllers/helpers/base_branch_helper.go b/pkg/gui/controllers/helpers/base_branch_helper.go index e921ce93d65..2386898a3a3 100644 --- a/pkg/gui/controllers/helpers/base_branch_helper.go +++ b/pkg/gui/controllers/helpers/base_branch_helper.go @@ -2,6 +2,9 @@ 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 @@ -35,3 +38,26 @@ func (self *BaseBranchHelper) ResolveBaseBranch(branch *models.Branch) (baseRef } 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: ShortBranchName(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/i18n/english.go b/pkg/i18n/english.go index ee5f4ceec15..63cea90b7a1 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -560,6 +560,8 @@ type TranslationSet struct { ViewDivergenceFromUpstream string ViewDivergenceFromBaseBranch string CouldNotDetermineBaseBranch string + PickBaseBranchTitle string + PickBaseBranchPrompt string DivergenceSectionHeaderLocal string DivergenceSectionHeaderRemote string ViewUpstreamResetOptions string @@ -1686,6 +1688,8 @@ 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.", DivergenceSectionHeaderLocal: "Local", DivergenceSectionHeaderRemote: "Remote", ViewUpstreamResetOptions: "Reset checked-out branch onto {{.upstream}}", From d1d6e3632f33d939b6bc7d04a566d5ef8eabeaa6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 21:20:01 +0200 Subject: [PATCH 09/17] Prompt for base branch when rebase-onto-base is ambiguous When the checked-out branch's fork point is contained in more than one configured main branch, pressing 'b' on the rebase menu now opens a small disambiguation menu first; the selection is recorded and then the rebase runs against the chosen base. The unambiguous case is unchanged. --- .../helpers/merge_and_rebase_helper.go | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index fa852bca845..cac9528b14f 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -273,12 +273,13 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { disabledReason = &types.DisabledReason{Text: self.c.Tr.CantRebaseOntoSelf} } - baseBranch, _, _, err := self.baseBranchHelper.ResolveBaseBranch(checkedOutBranch) + baseBranch, baseAmbiguous, baseCandidates, err := self.baseBranchHelper.ResolveBaseBranch(checkedOutBranch) if err != nil { return err } - if baseBranch == "" { - baseBranch = self.c.Tr.CouldNotDetermineBaseBranch + baseBranchLabel := baseBranch + if baseBranchLabel == "" { + baseBranchLabel = self.c.Tr.CouldNotDetermineBaseBranch baseBranchDisabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch} } @@ -335,27 +336,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": ShortBranchName(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) }, }, } From 902f6257877462534e2e71709582220b3ce1a6b1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 21:21:30 +0200 Subject: [PATCH 10/17] Prompt for base branch when viewing divergence is ambiguous Pressing 'b' on the branches view's divergence menu now shows the disambiguation menu when the selected branch's fork point is reachable from more than one configured main branch. The user's selection drives the sub-commits view and gets recorded so subsequent actions on the branch skip the prompt. --- pkg/gui/controllers/branches_controller.go | 30 +++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 133e57923fd..1b92224bc4d 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -285,15 +285,16 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc } var disabledReason *types.DisabledReason - baseBranch, _, _, err := self.c.Helpers().BaseBranch.ResolveBaseBranch(selectedBranch) + baseBranch, baseAmbiguous, baseCandidates, err := self.c.Helpers().BaseBranch.ResolveBaseBranch(selectedBranch) if err != nil { return err } - if baseBranch == "" { - baseBranch = self.c.Tr.CouldNotDetermineBaseBranch + baseBranchLabel := baseBranch + if baseBranchLabel == "" { + baseBranchLabel = self.c.Tr.CouldNotDetermineBaseBranch disabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch} } - shortBaseBranchName := helpers.ShortBranchName(baseBranch) + shortBaseBranchName := helpers.ShortBranchName(baseBranchLabel) label := utils.ResolvePlaceholderString( self.c.Tr.ViewDivergenceFromBaseBranch, map[string]string{"baseBranch": shortBaseBranchName}, @@ -306,14 +307,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.ShortBranchName(base)), + RefToShowDivergenceFrom: base, + Context: self.context(), + ShowBranchHeads: false, + }) + } + if baseAmbiguous { + return self.c.Helpers().BaseBranch.ShowPicker(branch, baseCandidates, showDivergence) + } + return showDivergence(baseBranch) }, DisabledReason: disabledReason, } From 02b79ef99f52f3248b458821eff73c887703a407 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 21:23:15 +0200 Subject: [PATCH 11/17] Prompt for base branch when move-commits-to-new-branch is ambiguous The "off of " item in the move-commits-to-new-branch menu now shows the disambiguation picker first when the base is ambiguous, then continues into the existing new-branch-name prompt with the chosen base. The "stacked on current branch" path doesn't use the base, so it's unaffected. --- pkg/gui/controllers/helpers/refs_helper.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 11def525551..c07e7a43392 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -422,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.baseBranchHelper.ResolveBaseBranch(currentBranch) + baseBranchRef, baseAmbiguous, baseCandidates, err := self.baseBranchHelper.ResolveBaseBranch(currentBranch) if err != nil { return err } @@ -485,9 +485,15 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error { { Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, shortBaseBranchName), OnPress: func() error { - return withNewBranchNamePrompt(shortBaseBranchName, func(newBranchName string) error { - return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, baseBranchRef) - }) + moveOff := func(base string) error { + return withNewBranchNamePrompt(ShortBranchName(base), func(newBranchName string) error { + return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, base) + }) + } + if baseAmbiguous { + return self.baseBranchHelper.ShowPicker(currentBranch, baseCandidates, moveOff) + } + return moveOff(baseBranchRef) }, }, { From 9b591eee56feb51f4944fe3346f1822d7e8baabe Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 23 May 2026 15:27:28 +0200 Subject: [PATCH 12/17] =?UTF-8?q?Show=20=3F=20and=20=E2=86=93=3F=20in=20th?= =?UTF-8?q?e=20branches=20list=20when=20the=20base=20is=20ambiguous?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a branch's fork point is reachable from more than one configured main branch and the candidates disagree on the behind count, the branches column was silently showing the config-order first candidate's number — confidently asserting something we don't actually know. Worse, "nothing" in the column means "up to date with the base", which may or may not be true under ambiguity. Compute behind values for every candidate (the fast path already had them; the legacy path now does too via baseBranchCandidatesAndBehinds), then classify: - all candidates agree → show that number (or nothing if 0) - some candidates 0, others not → "?" (we can't say if up to date) - all non-zero but differing → "↓?" (definitely behind, unknown amount) Two sentinel constants on the Branch model (BehindBaseAmbiguousMaybeUpToDate and BehindBaseAmbiguousDefinitelyBehind) encode these states in the existing atomic.Int32 field via negative values; the renderer switches on them. --- pkg/commands/git_commands/branch_loader.go | 130 ++++++++++-------- .../git_commands/branch_loader_test.go | 59 +++++--- pkg/commands/models/branch.go | 17 +++ pkg/gui/presentation/branches.go | 20 ++- pkg/gui/presentation/branches_test.go | 60 ++++++++ 5 files changed, 207 insertions(+), 79 deletions(-) diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index 2a519cf2f2d..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,35 +171,11 @@ func (self *BranchLoader) getBehindBaseBranchValuesLegacy( for _, branch := range branches { errg.Go(func() error { - candidates, err := self.GetBaseBranchCandidates(branch, mainBranches) + _, behinds, err := self.baseBranchCandidatesAndBehinds(branch, mainBranches) if err != nil { return err } - baseBranch := "" - if len(candidates) > 0 { - baseBranch = candidates[0] - } - 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 }) } @@ -271,19 +247,19 @@ func parseAheadBehindField(s string) aheadBehind { return aheadBehind{ahead: ahead, behind: behind, valid: true} } -// selectBaseForBranch picks the closest base 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). +// 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 the winning ref, its behind value, and the full set -// of refs tied at the minimum ahead (in config order). The caller can -// detect ambiguity via `len(candidates) > 1`. With no valid entry the -// return is ("", 0, nil). +// 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, -) (winner string, behind int, candidates []string) { +) (candidates []string, behinds []int) { bestAhead := -1 for i, ab := range aheadBehinds { if !ab.valid { @@ -292,14 +268,43 @@ func selectBaseForBranch( switch { case bestAhead < 0 || ab.ahead < bestAhead: bestAhead = ab.ahead - winner = mainRefs[i] - behind = ab.behind candidates = []string{mainRefs[i]} + behinds = []int{ab.behind} case ab.ahead == bestAhead: candidates = append(candidates, mainRefs[i]) + behinds = append(behinds, ab.behind) + } + } + return candidates, behinds +} + +// 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) } - return winner, behind, candidates + if anyZero { + return models.BehindBaseAmbiguousMaybeUpToDate + } + return models.BehindBaseAmbiguousDefinitelyBehind } // The output format is: @@ -325,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() @@ -342,8 +348,8 @@ func (self *BranchLoader) getBehindBaseBranchValuesFast( for _, p := range parsed { if branch, ok := branchByRef[p.refName]; ok { - _, behind, _ := selectBaseForBranch(p.aheadBehinds, mainBranchRefs) - branch.BehindBaseBranch.Store(int32(behind)) + _, behinds := selectBaseForBranch(p.aheadBehinds, mainBranchRefs) + branch.BehindBaseBranch.Store(classifyBehind(behinds)) delete(branchByRef, p.refName) } } @@ -365,9 +371,19 @@ func (self *BranchLoader) getBehindBaseBranchValuesFast( // 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, nil + return nil, nil, nil } mainBranchRefs := mainBranches.Get() @@ -380,11 +396,11 @@ func (self *BranchLoader) GetBaseBranchCandidates(branch *models.Branch, mainBra ToArgv(), ).DontLog().RunWithOutput() if err != nil { - return nil, err + return nil, nil, err } trimmedOutput := strings.TrimSpace(output) if trimmedOutput == "" { - return nil, nil + return nil, nil, nil } contained := strings.Split(trimmedOutput, "\n") @@ -394,14 +410,15 @@ func (self *BranchLoader) GetBaseBranchCandidates(branch *models.Branch, mainBra containing := lo.Filter(mainBranchRefs, func(ref string, _ int) bool { return lo.Contains(contained, ref) }) - if len(containing) <= 1 { - return containing, nil + if len(containing) == 0 { + return nil, nil, nil } - // Multiple main branches contain the merge-base. Measure ahead/behind - // against each 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. + // 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( @@ -412,17 +429,18 @@ func (self *BranchLoader) GetBaseBranchCandidates(branch *models.Branch, mainBra ToArgv(), ).DontLog().RunWithOutput() if err != nil { - return nil, err + return nil, nil, err } aheadBehinds[i] = parseAheadBehindField(strings.TrimSpace(revListOutput)) } - _, _, candidates := selectBaseForBranch(aheadBehinds, containing) + candidates, behinds := selectBaseForBranch(aheadBehinds, containing) if len(candidates) == 0 { - // Every rev-list output was malformed; fall back to config order. - return containing, nil + // Every rev-list output was malformed; fall back to config order + // with no reliable behinds. + return containing, nil, nil } - return candidates, 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 07b6d15847d..b3a3ade40f1 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -248,14 +248,39 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { } } +func TestClassifyBehind(t *testing.T) { + type scenario struct { + 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 - expectedWinner string - expectedBehind int expectedCandidates []string + expectedBehinds []int } scenarios := []scenario{ @@ -263,9 +288,8 @@ func TestSelectBaseForBranch(t *testing.T) { testName: "single base, valid value", aheadBehinds: []aheadBehind{{ahead: 3, behind: 7, valid: true}}, mainRefs: []string{"refs/heads/master"}, - expectedWinner: "refs/heads/master", - expectedBehind: 7, expectedCandidates: []string{"refs/heads/master"}, + expectedBehinds: []int{7}, }, { testName: "multi-base, clear winner by ahead", @@ -274,9 +298,8 @@ func TestSelectBaseForBranch(t *testing.T) { {ahead: 5, behind: 2, valid: true}, // develop ← smallest ahead }, mainRefs: []string{"refs/heads/master", "refs/heads/develop"}, - expectedWinner: "refs/heads/develop", - expectedBehind: 2, expectedCandidates: []string{"refs/heads/develop"}, + expectedBehinds: []int{2}, }, { testName: "develop forked from master case (ancestor-of-each-other)", @@ -289,23 +312,21 @@ func TestSelectBaseForBranch(t *testing.T) { {ahead: 5, behind: 5, valid: true}, // develop ← smallest ahead }, mainRefs: []string{"refs/heads/master", "refs/heads/develop"}, - expectedWinner: "refs/heads/develop", - expectedBehind: 5, 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, valid: true}, // first {ahead: 5, behind: 99, valid: true}, // second, same ahead }, - mainRefs: []string{"refs/heads/main", "refs/heads/develop"}, - expectedWinner: "refs/heads/main", - expectedBehind: 10, + mainRefs: []string{"refs/heads/main", "refs/heads/develop"}, expectedCandidates: []string{ "refs/heads/main", "refs/heads/develop", }, + expectedBehinds: []int{10, 99}, }, { testName: "first base invalid, second valid", @@ -314,9 +335,8 @@ func TestSelectBaseForBranch(t *testing.T) { {ahead: 3, behind: 8, valid: true}, }, mainRefs: []string{"refs/heads/master", "refs/heads/develop"}, - expectedWinner: "refs/heads/develop", - expectedBehind: 8, expectedCandidates: []string{"refs/heads/develop"}, + expectedBehinds: []int{8}, }, { testName: "all invalid - returns empty", @@ -325,26 +345,23 @@ func TestSelectBaseForBranch(t *testing.T) { {valid: false}, }, mainRefs: []string{"refs/heads/master", "refs/heads/develop"}, - expectedWinner: "", - expectedBehind: 0, expectedCandidates: nil, + expectedBehinds: nil, }, { testName: "empty - returns empty", aheadBehinds: nil, mainRefs: nil, - expectedWinner: "", - expectedBehind: 0, expectedCandidates: nil, + expectedBehinds: nil, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { - winner, behind, candidates := selectBaseForBranch(s.aheadBehinds, s.mainRefs) - assert.Equal(t, s.expectedWinner, winner) - assert.Equal(t, s.expectedBehind, behind) + candidates, behinds := selectBaseForBranch(s.aheadBehinds, s.mainRefs) assert.Equal(t, s.expectedCandidates, candidates) + assert.Equal(t, s.expectedBehinds, behinds) }) } } 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/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, From a507d56f7757506ed3e4f8006c61cfb9c62f5cbd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 21:43:53 +0200 Subject: [PATCH 13/17] Document mainBranches and the disambiguation prompt --- docs-master/Config.md | 4 +++- pkg/config/app_config_test.go | 2 +- pkg/config/user_config.go | 2 +- schema-master/config.json | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) 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/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/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" From 3c2016d1dfdf50edba50e2a09a21bca036e6dba8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 22 May 2026 18:16:30 +0200 Subject: [PATCH 14/17] Signal ambiguity in the rebase-onto-base-branch label The label currently looks identical in the unambiguous case ("Rebase onto base branch (develop)") and the ambiguous case where develop is just the config-order tiebreak; pressing 'b' would then surprise the user with a picker. Show "pick: main, develop" in the parenthetical when the resolver reports the base is ambiguous so the upcoming prompt is no longer a surprise. The new PickBaseBranchLabel i18n string lives next to the existing PickBaseBranchTitle/Prompt so the disambiguation UI is grouped in one place. --- .../controllers/helpers/merge_and_rebase_helper.go | 14 +++++++++++--- pkg/i18n/english.go | 2 ++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index cac9528b14f..9cf823f16b9 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -277,10 +277,18 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { if err != nil { return err } - baseBranchLabel := baseBranch - if baseBranchLabel == "" { + baseBranchLabel := ShortBranchName(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 ShortBranchName(ref) + }) + baseBranchLabel = utils.ResolvePlaceholderString(self.c.Tr.PickBaseBranchLabel, + map[string]string{"candidates": strings.Join(shortNames, ", ")}, + ) } menuItems := []*types.MenuItem{ @@ -336,7 +344,7 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { }, { Label: utils.ResolvePlaceholderString(self.c.Tr.RebaseOntoBaseBranch, - map[string]string{"baseBranch": ShortBranchName(baseBranchLabel)}, + map[string]string{"baseBranch": baseBranchLabel}, ), Keys: menuKey('b'), DisabledReason: baseBranchDisabledReason, diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 63cea90b7a1..e486c1973bb 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -562,6 +562,7 @@ type TranslationSet struct { CouldNotDetermineBaseBranch string PickBaseBranchTitle string PickBaseBranchPrompt string + PickBaseBranchLabel string DivergenceSectionHeaderLocal string DivergenceSectionHeaderRemote string ViewUpstreamResetOptions string @@ -1690,6 +1691,7 @@ func EnglishTranslationSet() *TranslationSet { 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}}", From 0111fe67afe7bc212e3e37e6803708f7d5f74e69 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 22 May 2026 18:18:02 +0200 Subject: [PATCH 15/17] Signal ambiguity in the view-divergence-from-base-branch label Same idea as the rebase menu's label: when the resolver reports the selected branch's base is ambiguous, show "pick: main, develop" in the parenthetical instead of just the config-order tiebreak, so the user knows the prompt will appear before they press 'b'. --- pkg/gui/controllers/branches_controller.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 1b92224bc4d..e6c9e353d9e 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -289,15 +289,22 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc if err != nil { return err } - baseBranchLabel := baseBranch - if baseBranchLabel == "" { + baseBranchLabel := helpers.ShortBranchName(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.ShortBranchName(ref) + }) + baseBranchLabel = utils.ResolvePlaceholderString(self.c.Tr.PickBaseBranchLabel, + map[string]string{"candidates": strings.Join(shortNames, ", ")}, + ) } - shortBaseBranchName := helpers.ShortBranchName(baseBranchLabel) label := utils.ResolvePlaceholderString( self.c.Tr.ViewDivergenceFromBaseBranch, - map[string]string{"baseBranch": shortBaseBranchName}, + map[string]string{"baseBranch": baseBranchLabel}, ) viewDivergenceFromBaseBranchItem := &types.MenuItem{ LabelColumns: []string{label}, From ccfb94a5f0f9866be69f9e909836aa66d73e13f1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 22 May 2026 18:19:23 +0200 Subject: [PATCH 16/17] Signal ambiguity in the move-commits-to-new-branch label and prompt Same idea as the rebase and view-divergence labels: when the base is ambiguous, the menu prompt and the "New branch from base branch (...)" item both substitute "pick: main, develop" for the parenthetical so the user knows the disambiguation picker will appear before they pick the "from base" option. --- pkg/gui/controllers/helpers/refs_helper.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index c07e7a43392..b7c98e503ab 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -471,11 +471,19 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error { return nil } - shortBaseBranchName := ShortBranchName(baseBranchRef) + baseBranchLabel := ShortBranchName(baseBranchRef) + if baseAmbiguous { + shortNames := lo.Map(baseCandidates, func(ref string, _ int) string { + return ShortBranchName(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{ @@ -483,7 +491,7 @@ 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 { moveOff := func(base string) error { return withNewBranchNamePrompt(ShortBranchName(base), func(newBranchName string) error { From 7fd8392610276f5d8cc7bb820a178b6de0ca3ab4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 22 May 2026 18:33:20 +0200 Subject: [PATCH 17/17] Show base branches as bare names in labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShortBranchName previously turned "refs/remotes/origin/main" into "origin/main", leaking the resolved-ref shape into UI labels that the user thinks of as plain "main" — the name they put in their mainBranches config. With the ambiguous-base label now potentially listing several branches ("pick: origin/main, origin/13"), the noise is even more pronounced. Strip the remote name along with the "refs/remotes/" prefix so the short name matches what the user configured. Rename the helper to BaseBranchDisplayName to make the constraint explicit at every call site: dropping the remote is a sensible choice only for base-branch display, not for refs in general. All current callers happen to be base-branch related, so the rename is just a scope-tightening. Existing integration test updated to expect the bare "master" form. --- pkg/gui/controllers/branches_controller.go | 6 ++--- .../controllers/helpers/base_branch_helper.go | 2 +- .../controllers/helpers/branches_helper.go | 26 +++++++++++++++++-- .../helpers/merge_and_rebase_helper.go | 4 +-- pkg/gui/controllers/helpers/refs_helper.go | 6 ++--- ..._commits_to_new_branch_from_base_branch.go | 4 +-- 6 files changed, 35 insertions(+), 13 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index e6c9e353d9e..b2454b2c4ee 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -289,14 +289,14 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc if err != nil { return err } - baseBranchLabel := helpers.ShortBranchName(baseBranch) + 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.ShortBranchName(ref) + return helpers.BaseBranchDisplayName(ref) }) baseBranchLabel = utils.ResolvePlaceholderString(self.c.Tr.PickBaseBranchLabel, map[string]string{"candidates": strings.Join(shortNames, ", ")}, @@ -317,7 +317,7 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc showDivergence := func(base string) error { return self.c.Helpers().SubCommits.ViewSubCommits(helpers.ViewSubCommitsOpts{ Ref: branch, - TitleRef: fmt.Sprintf("%s <-> %s", branch.RefName(), helpers.ShortBranchName(base)), + TitleRef: fmt.Sprintf("%s <-> %s", branch.RefName(), helpers.BaseBranchDisplayName(base)), RefToShowDivergenceFrom: base, Context: self.context(), ShowBranchHeads: false, diff --git a/pkg/gui/controllers/helpers/base_branch_helper.go b/pkg/gui/controllers/helpers/base_branch_helper.go index 2386898a3a3..8db870e9601 100644 --- a/pkg/gui/controllers/helpers/base_branch_helper.go +++ b/pkg/gui/controllers/helpers/base_branch_helper.go @@ -50,7 +50,7 @@ func (self *BaseBranchHelper) ShowPicker( ) error { items := lo.Map(candidates, func(ref string, _ int) *types.MenuItem { return &types.MenuItem{ - Label: ShortBranchName(ref), + Label: BaseBranchDisplayName(ref), OnPress: func() error { return onPicked(ref) }, } }) 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/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 9cf823f16b9..a419aa6ac17 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -277,14 +277,14 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { if err != nil { return err } - baseBranchLabel := ShortBranchName(baseBranch) + 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 ShortBranchName(ref) + return BaseBranchDisplayName(ref) }) baseBranchLabel = utils.ResolvePlaceholderString(self.c.Tr.PickBaseBranchLabel, map[string]string{"candidates": strings.Join(shortNames, ", ")}, diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index b7c98e503ab..6d12fb31df1 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -471,10 +471,10 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error { return nil } - baseBranchLabel := ShortBranchName(baseBranchRef) + baseBranchLabel := BaseBranchDisplayName(baseBranchRef) if baseAmbiguous { shortNames := lo.Map(baseCandidates, func(ref string, _ int) string { - return ShortBranchName(ref) + return BaseBranchDisplayName(ref) }) baseBranchLabel = utils.ResolvePlaceholderString(self.c.Tr.PickBaseBranchLabel, map[string]string{"candidates": strings.Join(shortNames, ", ")}, @@ -494,7 +494,7 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error { Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, baseBranchLabel), OnPress: func() error { moveOff := func(base string) error { - return withNewBranchNamePrompt(ShortBranchName(base), func(newBranchName string) error { + return withNewBranchNamePrompt(BaseBranchDisplayName(base), func(newBranchName string) error { return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, base) }) } 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()