Compare commits

...
Author SHA1 Message Date
Stefan Haller 6e74cc0268 Show base branches as bare names in labels
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.
2026-07-03 19:37:37 +02:00
Stefan Haller cee2de60ba 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.
2026-07-03 19:37:37 +02:00
Stefan Haller d688fa9319 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'.
2026-07-03 19:37:37 +02:00
Stefan Haller 272235925b 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.
2026-07-03 19:37:37 +02:00
Stefan Haller 2efaa38124 Document mainBranches and the disambiguation prompt 2026-07-03 19:37:37 +02:00
Stefan Haller df138a4c72 Show ? and ↓? in the branches list when the base is ambiguous
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.
2026-07-03 19:37:37 +02:00
Stefan Haller acc15a6e43 Prompt for base branch when move-commits-to-new-branch is ambiguous
The "off of <base>" 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.
2026-07-03 19:37:37 +02:00
Stefan Haller dcf0532020 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.
2026-07-03 19:37:37 +02:00
Stefan Haller f59f993d1c 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.
2026-07-03 19:37:37 +02:00
Stefan Haller c9eb915279 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).
2026-07-03 19:37:37 +02:00
Stefan Haller 98a421091f Route base-branch lookups through the shared resolver
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.
2026-07-03 19:37:37 +02:00
Stefan Haller 672a37031d Introduce BaseBranchHelper around candidate resolution
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.
2026-07-03 19:35:33 +02:00
Stefan Haller 84bd2b6fe1 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.
2026-07-03 19:35:33 +02:00
Stefan Haller 5a9e8c6af9 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.
2026-07-03 19:35:33 +02:00
Stefan Haller 28cfcaced2 Reshape selection to expose the winning base ref
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.
2026-07-03 19:35:33 +02:00
Stefan Haller dd03884087 Pick base branch by smallest ahead instead of relying on for-each-ref's order
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.
2026-07-03 19:35:33 +02:00
Stefan Haller a0e51da643 Add test demonstrating wrong base branch on ambiguous merge-base
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.
2026-07-03 19:35:33 +02:00
18 changed files with 618 additions and 152 deletions
+3 -1
View File
@@ -419,7 +419,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
+138 -57
View File
@@ -156,7 +156,7 @@ func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches(
}
if self.version.IsAtLeast(2, 41, 0) {
return self.getBehindBaseBranchValuesFast(branches, mainBranchRefs, renderFunc)
return self.getBehindBaseBranchValuesFast(branches, mainBranches, renderFunc)
}
return self.getBehindBaseBranchValuesLegacy(branches, mainBranches, renderFunc)
}
@@ -171,31 +171,11 @@ func (self *BranchLoader) getBehindBaseBranchValuesLegacy(
for _, branch := range branches {
errg.Go(func() error {
baseBranch, err := self.GetBaseBranch(branch, mainBranches)
_, behinds, err := self.baseBranchCandidatesAndBehinds(branch, mainBranches)
if err != nil {
return err
}
behind := 0 // prime it in case something below fails
if baseBranch != "" {
output, err := self.cmd.New(
NewGitCmd("rev-list").
Arg("--left-right").
Arg("--count").
Arg(fmt.Sprintf("%s...%s", branch.FullRefName(), baseBranch)).
ToArgv(),
).DontLog().RunWithOutput()
if err != nil {
return err
}
// The format of the output is "<ahead>\t<behind>"
aheadBehindStr := strings.Split(strings.TrimSpace(output), "\t")
if len(aheadBehindStr) == 2 {
if value, err := strconv.Atoi(aheadBehindStr[1]); err == nil {
behind = value
}
}
}
branch.BehindBaseBranch.Store(int32(behind))
branch.BehindBaseBranch.Store(classifyBehind(behinds))
return nil
})
}
@@ -206,9 +186,13 @@ func (self *BranchLoader) getBehindBaseBranchValuesLegacy(
return err
}
// Holds parsed values from a single %(ahead-behind:<base>) field.
// Holds parsed values from a single %(ahead-behind:<base>) field. `valid`
// is false when the field failed to parse (e.g. the base was unreachable
// from this ref); the entry is preserved so that the slice stays index-
// aligned with the configured main branches.
type aheadBehind struct {
ahead, behind int
valid bool
}
type branchAheadBehind struct {
@@ -222,7 +206,7 @@ type branchAheadBehind struct {
//
// Lines whose NUL-split column count doesn't match (1 + numBases) are dropped.
// Blank lines are ignored.
// Individual malformed ahead-behind fields produce {valid: false} entries
// Individual malformed ahead-behind fields produce {valid: false} entries.
func parseAheadBehindForEachRefOutput(
output string,
numBases int, // number of %(ahead-behind:...) tokens
@@ -238,7 +222,7 @@ func parseAheadBehindForEachRefOutput(
continue
}
refName := cols[0]
aheadBehinds := lo.FilterMap(cols[1:], func(col string, _ int) (aheadBehind, bool) {
aheadBehinds := lo.Map(cols[1:], func(col string, _ int) aheadBehind {
return parseAheadBehindField(col)
})
entry := branchAheadBehind{
@@ -250,27 +234,77 @@ func parseAheadBehindForEachRefOutput(
return result
}
func parseAheadBehindField(s string) (aheadBehind, bool) {
func parseAheadBehindField(s string) aheadBehind {
parts := strings.Fields(s)
if len(parts) != 2 {
return aheadBehind{}, false
return aheadBehind{}
}
ahead, err1 := strconv.Atoi(parts[0])
behind, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil {
return aheadBehind{}, false
return aheadBehind{}
}
return aheadBehind{ahead: ahead, behind: behind}, 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(s) for a branch given
// (ahead, behind) measurements against each configured main branch.
// "Closest" = smallest ahead value (fewest branch commits not in the
// base). Ties are broken by the order of mainRefs (i.e. config order).
//
// aheadBehinds must be index-aligned with mainRefs; invalid entries are
// skipped. Returns parallel slices: the refs tied at the minimum ahead
// (in config order) and their behind values. The caller picks
// candidates[0] for a single answer, or detects ambiguity via
// `len(candidates) > 1`.
func selectBaseForBranch(
aheadBehinds []aheadBehind, mainRefs []string,
) (candidates []string, behinds []int) {
bestAhead := -1
for i, ab := range aheadBehinds {
if !ab.valid {
continue
}
switch {
case bestAhead < 0 || ab.ahead < bestAhead:
bestAhead = ab.ahead
candidates = []string{mainRefs[i]}
behinds = []int{ab.behind}
case ab.ahead == bestAhead:
candidates = append(candidates, mainRefs[i])
behinds = append(behinds, ab.behind)
}
}
return 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)
}
if anyZero {
return models.BehindBaseAmbiguousMaybeUpToDate
}
return models.BehindBaseAmbiguousDefinitelyBehind
}
// The output format is:
@@ -296,11 +330,12 @@ func buildAheadBehindForEachRefArgs(mainBranchRefs []string) []string {
func (self *BranchLoader) getBehindBaseBranchValuesFast(
branches []*models.Branch,
mainBranchRefs []string,
mainBranches *MainBranches,
renderFunc func(),
) error {
t := time.Now()
mainBranchRefs := mainBranches.Get()
output, err := self.cmd.New(
buildAheadBehindForEachRefArgs(mainBranchRefs),
).DontLog().RunWithOutput()
@@ -313,8 +348,8 @@ func (self *BranchLoader) getBehindBaseBranchValuesFast(
for _, p := range parsed {
if branch, ok := branchByRef[p.refName]; ok {
behind := selectBehindForBranch(p.aheadBehinds)
branch.BehindBaseBranch.Store(int32(behind))
_, behinds := selectBaseForBranch(p.aheadBehinds, mainBranchRefs)
branch.BehindBaseBranch.Store(classifyBehind(behinds))
delete(branchByRef, p.refName)
}
}
@@ -329,37 +364,83 @@ func (self *BranchLoader) getBehindBaseBranchValuesFast(
return nil
}
// Find the base branch for the given branch (i.e. the main branch that the
// given branch was forked off of)
//
// Note that this function may return an empty string even if the returned error
// is nil, e.g. when none of the configured main branches exist. This is not
// considered an error condition, so callers need to check both the returned
// error and whether the returned base branch is empty (and possibly react
// differently in both cases).
func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *MainBranches) (string, error) {
// GetBaseBranchCandidates returns the configured main branches that are the
// closest base for the given branch — typically a single ref, but more
// when the closeness rule (smallest ahead value) leaves a tie. Candidates
// are returned in config order, so callers wanting one answer can use
// candidates[0] as the config-order tiebreak. An empty slice (with nil
// error) means no configured main branch contains the branch's merge-base.
func (self *BranchLoader) GetBaseBranchCandidates(branch *models.Branch, mainBranches *MainBranches) ([]string, error) {
candidates, _, err := self.baseBranchCandidatesAndBehinds(branch, mainBranches)
return candidates, err
}
// baseBranchCandidatesAndBehinds is the full computation behind
// GetBaseBranchCandidates: it also reports the behind count for each
// returned candidate, which the legacy behind-base loader needs in order
// to classify the column display when the candidates disagree. Slices
// are parallel and in config order.
func (self *BranchLoader) baseBranchCandidatesAndBehinds(branch *models.Branch, mainBranches *MainBranches) ([]string, []int, error) {
mergeBase := mainBranches.GetMergeBase(branch.FullRefName())
if mergeBase == "" {
return "", nil
return nil, nil, nil
}
mainBranchRefs := mainBranches.Get()
output, err := self.cmd.New(
NewGitCmd("for-each-ref").
Arg("--contains").
Arg(mergeBase).
Arg("--format=%(refname)").
Arg(mainBranches.Get()...).
Arg(mainBranchRefs...).
ToArgv(),
).DontLog().RunWithOutput()
if err != nil {
return "", err
return nil, nil, err
}
trimmedOutput := strings.TrimSpace(output)
split := strings.Split(trimmedOutput, "\n")
if len(split) == 0 || split[0] == "" {
return "", nil
if trimmedOutput == "" {
return nil, nil, nil
}
return split[0], nil
contained := strings.Split(trimmedOutput, "\n")
// for-each-ref sorts its output alphabetically by refname regardless of
// the order we passed the refs in. Restore the user's configured order so
// it can serve as the natural tiebreaker.
containing := lo.Filter(mainBranchRefs, func(ref string, _ int) bool {
return lo.Contains(contained, ref)
})
if len(containing) == 0 {
return nil, nil, nil
}
// Measure ahead/behind against each containing ref and hand off to
// selectBaseForBranch — the same selector the fast path uses — so
// both paths agree on the closeness rule and the config-order
// tiebreak. We do this even when there's only one containing ref,
// because the legacy column display still needs the behind value.
aheadBehinds := make([]aheadBehind, len(containing))
for i, ref := range containing {
revListOutput, err := self.cmd.New(
NewGitCmd("rev-list").
Arg("--left-right").
Arg("--count").
Arg(fmt.Sprintf("%s...%s", branch.FullRefName(), ref)).
ToArgv(),
).DontLog().RunWithOutput()
if err != nil {
return nil, nil, err
}
aheadBehinds[i] = parseAheadBehindField(strings.TrimSpace(revListOutput))
}
candidates, behinds := selectBaseForBranch(aheadBehinds, containing)
if len(candidates) == 0 {
// Every rev-list output was malformed; fall back to config order
// with no reliable behinds.
return containing, nil, nil
}
return candidates, behinds, nil
}
func (self *BranchLoader) obtainBranches() []*models.Branch {
+186 -40
View File
@@ -142,7 +142,7 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) {
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{{ahead: 2, behind: 5}},
aheadBehinds: []aheadBehind{{ahead: 2, behind: 5, valid: true}},
},
},
},
@@ -155,15 +155,15 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) {
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{
{ahead: 2, behind: 5},
{ahead: 10, behind: 1},
{ahead: 2, behind: 5, valid: true},
{ahead: 10, behind: 1, valid: true},
},
},
{
refName: "refs/heads/main",
aheadBehinds: []aheadBehind{
{ahead: 0, behind: 0},
{ahead: 0, behind: 0},
{ahead: 0, behind: 0, valid: true},
{ahead: 0, behind: 0, valid: true},
},
},
},
@@ -176,7 +176,8 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) {
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{
{ahead: 2, behind: 5},
{valid: false},
{ahead: 2, behind: 5, valid: true},
},
},
},
@@ -188,7 +189,7 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) {
expected: []branchAheadBehind{
{
refName: "refs/heads/feat/foo-bar",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}},
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}},
},
},
},
@@ -199,7 +200,7 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) {
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}},
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}},
},
},
},
@@ -212,11 +213,11 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) {
expected: []branchAheadBehind{
{
refName: "refs/heads/good",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}},
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}},
},
{
refName: "refs/heads/also_good",
aheadBehinds: []aheadBehind{{ahead: 3, behind: 4}},
aheadBehinds: []aheadBehind{{ahead: 3, behind: 4, valid: true}},
},
},
},
@@ -227,7 +228,7 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) {
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{},
aheadBehinds: []aheadBehind{{valid: false}},
},
},
},
@@ -247,26 +248,58 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) {
}
}
func TestSelectBehindForBranch(t *testing.T) {
func TestClassifyBehind(t *testing.T) {
type scenario struct {
testName string
aheadBehinds []aheadBehind
expected int
testName string
behinds []int
expected int32
}
scenarios := []scenario{
{"empty", nil, 0},
{"single zero", []int{0}, 0},
{"single non-zero", []int{5}, 5},
{"all equal zero", []int{0, 0, 0}, 0},
{"all equal non-zero", []int{7, 7}, 7},
{"mixed zero and non-zero → ?", []int{0, 5}, models.BehindBaseAmbiguousMaybeUpToDate},
{"mixed zero and non-zero, reversed → ?", []int{5, 0}, models.BehindBaseAmbiguousMaybeUpToDate},
{"all non-zero, different → ↓?", []int{3, 5}, models.BehindBaseAmbiguousDefinitelyBehind},
{"all non-zero, different (three) → ↓?", []int{3, 5, 7}, models.BehindBaseAmbiguousDefinitelyBehind},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
assert.Equal(t, s.expected, classifyBehind(s.behinds))
})
}
}
func TestSelectBaseForBranch(t *testing.T) {
type scenario struct {
testName string
aheadBehinds []aheadBehind
mainRefs []string
expectedCandidates []string
expectedBehinds []int
}
scenarios := []scenario{
{
testName: "single base, valid value",
aheadBehinds: []aheadBehind{{ahead: 3, behind: 7}},
expected: 7,
testName: "single base, valid value",
aheadBehinds: []aheadBehind{{ahead: 3, behind: 7, valid: true}},
mainRefs: []string{"refs/heads/master"},
expectedCandidates: []string{"refs/heads/master"},
expectedBehinds: []int{7},
},
{
testName: "multi-base, clear winner by ahead",
aheadBehinds: []aheadBehind{
{ahead: 50, behind: 10}, // master
{ahead: 5, behind: 2}, // develop ← smallest ahead
{ahead: 50, behind: 10, valid: true}, // master
{ahead: 5, behind: 2, valid: true}, // develop ← smallest ahead
},
expected: 2,
mainRefs: []string{"refs/heads/master", "refs/heads/develop"},
expectedCandidates: []string{"refs/heads/develop"},
expectedBehinds: []int{2},
},
{
testName: "develop forked from master case (ancestor-of-each-other)",
@@ -275,42 +308,60 @@ func TestSelectBehindForBranch(t *testing.T) {
// ahead vs master = 5 + 50 = 55; behind vs master = 0
// ahead vs develop = 5; behind vs develop = 5
aheadBehinds: []aheadBehind{
{ahead: 55, behind: 0}, // master
{ahead: 5, behind: 5}, // develop ← smallest ahead
{ahead: 55, behind: 0, valid: true}, // master
{ahead: 5, behind: 5, valid: true}, // develop ← smallest ahead
},
expected: 5,
mainRefs: []string{"refs/heads/master", "refs/heads/develop"},
expectedCandidates: []string{"refs/heads/develop"},
expectedBehinds: []int{5},
},
{
testName: "tie on ahead - first base wins (config order)",
testName: "tie on ahead - both candidates returned in config order",
aheadBehinds: []aheadBehind{
{ahead: 5, behind: 10}, // first
{ahead: 5, behind: 99}, // second, same ahead
{ahead: 5, behind: 10, valid: true}, // first
{ahead: 5, behind: 99, valid: true}, // second, same ahead
},
expected: 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",
aheadBehinds: []aheadBehind{
{ahead: 3, behind: 8},
{valid: false},
{ahead: 3, behind: 8, valid: true},
},
expected: 8,
mainRefs: []string{"refs/heads/master", "refs/heads/develop"},
expectedCandidates: []string{"refs/heads/develop"},
expectedBehinds: []int{8},
},
{
testName: "all invalid - returns 0",
aheadBehinds: []aheadBehind{},
expected: 0,
testName: "all invalid - returns empty",
aheadBehinds: []aheadBehind{
{valid: false},
{valid: false},
},
mainRefs: []string{"refs/heads/master", "refs/heads/develop"},
expectedCandidates: nil,
expectedBehinds: nil,
},
{
testName: "empty - returns 0",
aheadBehinds: nil,
expected: 0,
testName: "empty - returns empty",
aheadBehinds: nil,
mainRefs: nil,
expectedCandidates: nil,
expectedBehinds: nil,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := selectBehindForBranch(s.aheadBehinds)
assert.Equal(t, s.expected, result)
candidates, behinds := selectBaseForBranch(s.aheadBehinds, s.mainRefs)
assert.Equal(t, s.expectedCandidates, candidates)
assert.Equal(t, s.expectedBehinds, behinds)
})
}
}
@@ -458,8 +509,8 @@ func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) {
{Name: "feat-x"},
}
// In legacy path: per-branch GetBaseBranch (merge-base + for-each-ref --contains)
// then rev-list --left-right --count.
// In legacy path: per-branch GetBaseBranchCandidates (merge-base +
// for-each-ref --contains) then rev-list --left-right --count.
runner := oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"merge-base", "refs/heads/feat-x", "refs/heads/master"}, "abc123\n", nil).
ExpectGitArgs([]string{"for-each-ref", "--contains", "abc123", "--format=%(refname)", "refs/heads/master"}, "refs/heads/master\n", nil).
@@ -491,3 +542,98 @@ func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) {
runner.CheckForMissingCalls()
}
// When the branch's merge-base is contained in more than one configured main
// branch and the ahead counts are equal, the candidate list must preserve
// the user's configured order rather than the alphabetical order of
// for-each-ref's output.
func TestGetBaseBranchCandidates_AmbiguousReturnsAllInConfigOrder(t *testing.T) {
mainBranchRefs := []string{"refs/heads/main", "refs/heads/develop"}
branch := &models.Branch{Name: "feat-x"}
runner := oscommands.NewFakeRunner(t).
ExpectGitArgs(
[]string{"merge-base", "refs/heads/feat-x", "refs/heads/main", "refs/heads/develop"},
"abc123\n", nil).
ExpectGitArgs(
[]string{
"for-each-ref", "--contains", "abc123", "--format=%(refname)",
"refs/heads/main", "refs/heads/develop",
},
"refs/heads/develop\nrefs/heads/main\n", nil).
ExpectGitArgs(
[]string{"rev-list", "--left-right", "--count", "refs/heads/feat-x...refs/heads/main"},
"5\t10\n", nil).
ExpectGitArgs(
[]string{"rev-list", "--left-right", "--count", "refs/heads/feat-x...refs/heads/develop"},
"5\t8\n", nil)
gitCommon := buildGitCommon(commonDeps{runner: runner})
loader := &BranchLoader{
Common: gitCommon.Common,
GitCommon: gitCommon,
cmd: gitCommon.cmd,
}
mainBranches := &MainBranches{
c: gitCommon.Common,
cmd: gitCommon.cmd,
existingMainBranches: mainBranchRefs,
previousMainBranches: gitCommon.Common.UserConfig().Git.MainBranches,
}
candidates, err := loader.GetBaseBranchCandidates(branch, mainBranches)
assert.NoError(t, err)
assert.Equal(t, []string{"refs/heads/main", "refs/heads/develop"}, candidates)
runner.CheckForMissingCalls()
}
// When a configured main branch has a strictly smaller ahead count than any
// other (e.g. the branch was forked off `main` after main's last merge into
// `develop`, so `develop` doesn't yet contain the fork point's recent main
// history), that base wins outright regardless of config order, so only
// that one ref is returned.
func TestGetBaseBranchCandidates_UnambiguousReturnsSmallestAheadOnly(t *testing.T) {
mainBranchRefs := []string{"refs/heads/develop", "refs/heads/main"}
branch := &models.Branch{Name: "feat-x"}
runner := oscommands.NewFakeRunner(t).
ExpectGitArgs(
[]string{"merge-base", "refs/heads/feat-x", "refs/heads/develop", "refs/heads/main"},
"abc123\n", nil).
ExpectGitArgs(
[]string{
"for-each-ref", "--contains", "abc123", "--format=%(refname)",
"refs/heads/develop", "refs/heads/main",
},
"refs/heads/develop\nrefs/heads/main\n", nil).
ExpectGitArgs(
[]string{"rev-list", "--left-right", "--count", "refs/heads/feat-x...refs/heads/develop"},
"8\t3\n", nil).
ExpectGitArgs(
[]string{"rev-list", "--left-right", "--count", "refs/heads/feat-x...refs/heads/main"},
"5\t10\n", nil)
gitCommon := buildGitCommon(commonDeps{runner: runner})
loader := &BranchLoader{
Common: gitCommon.Common,
GitCommon: gitCommon,
cmd: gitCommon.cmd,
}
mainBranches := &MainBranches{
c: gitCommon.Common,
cmd: gitCommon.cmd,
existingMainBranches: mainBranchRefs,
previousMainBranches: gitCommon.Common.UserConfig().Git.MainBranches,
}
candidates, err := loader.GetBaseBranchCandidates(branch, mainBranches)
assert.NoError(t, err)
assert.Equal(t, []string{"refs/heads/main"}, candidates)
runner.CheckForMissingCalls()
}
+17
View File
@@ -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
+1 -1
View File
@@ -749,7 +749,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
+1 -1
View File
@@ -304,7 +304,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"`
+4 -2
View File
@@ -25,8 +25,9 @@ func (gui *Gui) resetHelpersAndControllers() {
helperCommon := gui.c
recordDirectoryHelper := helpers.NewRecordDirectoryHelper(helperCommon)
reposHelper := helpers.NewRecentReposHelper(helperCommon, recordDirectoryHelper, gui.onSwitchToNewRepo)
rebaseHelper := helpers.NewMergeAndRebaseHelper(helperCommon)
refsHelper := helpers.NewRefsHelper(helperCommon, rebaseHelper)
baseBranchHelper := helpers.NewBaseBranchHelper(helperCommon)
rebaseHelper := helpers.NewMergeAndRebaseHelper(helperCommon, baseBranchHelper)
refsHelper := helpers.NewRefsHelper(helperCommon, rebaseHelper, baseBranchHelper)
suggestionsHelper := helpers.NewSuggestionsHelper(helperCommon)
worktreeHelper := helpers.NewWorktreeHelper(helperCommon, reposHelper, refsHelper, suggestionsHelper)
@@ -129,6 +130,7 @@ func (gui *Gui) resetHelpersAndControllers() {
Search: searchHelper,
Worktree: worktreeHelper,
SubCommits: helpers.NewSubCommitsHelper(helperCommon, refreshHelper),
BaseBranch: helpers.NewBaseBranchHelper(helperCommon),
}
gui.CustomCommandsClient = custom_commands.NewClient(
+26 -13
View File
@@ -291,18 +291,26 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc
}
var disabledReason *types.DisabledReason
baseBranch, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(selectedBranch, self.c.Model().MainBranches)
baseBranch, baseAmbiguous, baseCandidates, err := self.c.Helpers().BaseBranch.ResolveBaseBranch(selectedBranch)
if err != nil {
return err
}
if baseBranch == "" {
baseBranch = self.c.Tr.CouldNotDetermineBaseBranch
baseBranchLabel := helpers.BaseBranchDisplayName(baseBranch)
switch {
case baseBranch == "":
baseBranchLabel = self.c.Tr.CouldNotDetermineBaseBranch
disabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch}
case baseAmbiguous:
shortNames := lo.Map(baseCandidates, func(ref string, _ int) string {
return helpers.BaseBranchDisplayName(ref)
})
baseBranchLabel = utils.ResolvePlaceholderString(self.c.Tr.PickBaseBranchLabel,
map[string]string{"candidates": strings.Join(shortNames, ", ")},
)
}
shortBaseBranchName := helpers.ShortBranchName(baseBranch)
label := utils.ResolvePlaceholderString(
self.c.Tr.ViewDivergenceFromBaseBranch,
map[string]string{"baseBranch": shortBaseBranchName},
map[string]string{"baseBranch": baseBranchLabel},
)
viewDivergenceFromBaseBranchItem := &types.MenuItem{
LabelColumns: []string{label},
@@ -312,14 +320,19 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc
if branch == nil {
return nil
}
return self.c.Helpers().SubCommits.ViewSubCommits(helpers.ViewSubCommitsOpts{
Ref: branch,
TitleRef: fmt.Sprintf("%s <-> %s", branch.RefName(), shortBaseBranchName),
RefToShowDivergenceFrom: baseBranch,
Context: self.context(),
ShowBranchHeads: false,
})
showDivergence := func(base string) error {
return self.c.Helpers().SubCommits.ViewSubCommits(helpers.ViewSubCommitsOpts{
Ref: branch,
TitleRef: fmt.Sprintf("%s <-> %s", branch.RefName(), helpers.BaseBranchDisplayName(base)),
RefToShowDivergenceFrom: base,
Context: self.context(),
ShowBranchHeads: false,
})
}
if baseAmbiguous {
return self.c.Helpers().BaseBranch.ShowPicker(branch, baseCandidates, showDivergence)
}
return showDivergence(baseBranch)
},
DisabledReason: disabledReason,
}
@@ -0,0 +1,63 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
// BaseBranchHelper resolves the base branch for a given branch. The
// closeness rule (smallest ahead value) usually picks a single answer
// but can leave a tie when the branch's fork point is reachable from
// more than one main branch — in that case the helper surfaces the
// candidates so the caller can disambiguate.
type BaseBranchHelper struct {
c *HelperCommon
}
func NewBaseBranchHelper(c *HelperCommon) *BaseBranchHelper {
return &BaseBranchHelper{c: c}
}
// ResolveBaseBranch returns the base branch for the given branch, the
// full set of tied candidates (for any disambiguation UI), and whether
// the answer is genuinely ambiguous (more than one candidate tied at
// the closest position).
//
// An empty baseRef (with no error) means no configured main branch
// contains the branch — not an error condition.
func (self *BaseBranchHelper) ResolveBaseBranch(branch *models.Branch) (baseRef string, ambiguous bool, candidates []string, err error) {
mainBranches := self.c.Model().MainBranches
candidates, err = self.c.Git().Loaders.BranchLoader.GetBaseBranchCandidates(branch, mainBranches)
if err != nil {
return "", false, nil, err
}
if len(candidates) == 0 {
return "", false, nil, nil
}
return candidates[0], len(candidates) > 1, candidates, nil
}
// ShowPicker presents a menu of candidate base branches and runs
// onPicked with the user's selection. Callers should only invoke this
// when ResolveBaseBranch reported ambiguous=true; for the
// single-candidate case there is nothing to pick.
func (self *BaseBranchHelper) ShowPicker(
branch *models.Branch,
candidates []string,
onPicked func(baseRef string) error,
) error {
items := lo.Map(candidates, func(ref string, _ int) *types.MenuItem {
return &types.MenuItem{
Label: BaseBranchDisplayName(ref),
OnPress: func() error { return onPicked(ref) },
}
})
return self.c.Menu(types.CreateMenuOptions{
Title: utils.ResolvePlaceholderString(self.c.Tr.PickBaseBranchTitle,
map[string]string{"branchName": branch.Name}),
Prompt: self.c.Tr.PickBaseBranchPrompt,
Items: items,
})
}
+24 -2
View File
@@ -161,8 +161,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 {
+2
View File
@@ -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{},
}
}
@@ -19,6 +19,8 @@ import (
type MergeAndRebaseHelper struct {
c *HelperCommon
baseBranchHelper *BaseBranchHelper
// Whether the "continue the rebase/merge?" prompt is currently on screen.
// We use this to auto-dismiss it if the operation stops being in the state
// that the prompt is offering to act on (e.g. it was continued or aborted
@@ -29,9 +31,11 @@ type MergeAndRebaseHelper struct {
func NewMergeAndRebaseHelper(
c *HelperCommon,
baseBranchHelper *BaseBranchHelper,
) *MergeAndRebaseHelper {
return &MergeAndRebaseHelper{
c: c,
c: c,
baseBranchHelper: baseBranchHelper,
}
}
@@ -343,13 +347,22 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
disabledReason = &types.DisabledReason{Text: self.c.Tr.CantRebaseOntoSelf}
}
baseBranch, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(checkedOutBranch, self.c.Model().MainBranches)
baseBranch, baseAmbiguous, baseCandidates, err := self.baseBranchHelper.ResolveBaseBranch(checkedOutBranch)
if err != nil {
return err
}
if baseBranch == "" {
baseBranch = self.c.Tr.CouldNotDetermineBaseBranch
baseBranchLabel := BaseBranchDisplayName(baseBranch)
switch {
case baseBranch == "":
baseBranchLabel = self.c.Tr.CouldNotDetermineBaseBranch
baseBranchDisabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch}
case baseAmbiguous:
shortNames := lo.Map(baseCandidates, func(ref string, _ int) string {
return BaseBranchDisplayName(ref)
})
baseBranchLabel = utils.ResolvePlaceholderString(self.c.Tr.PickBaseBranchLabel,
map[string]string{"candidates": strings.Join(shortNames, ", ")},
)
}
menuItems := []*types.MenuItem{
@@ -405,27 +418,33 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
},
{
Label: utils.ResolvePlaceholderString(self.c.Tr.RebaseOntoBaseBranch,
map[string]string{"baseBranch": ShortBranchName(baseBranch)},
map[string]string{"baseBranch": baseBranchLabel},
),
Keys: menuKey('b'),
DisabledReason: baseBranchDisabledReason,
Tooltip: self.c.Tr.RebaseOntoBaseBranchTooltip,
OnPress: func() error {
self.c.LogAction(self.c.Tr.Actions.RebaseBranch)
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error {
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
var err error
if baseCommit != "" {
err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(baseBranch, baseCommit)
} else {
err = self.c.Git().Rebase.RebaseBranch(baseBranch)
}
err = self.CheckMergeOrRebase(err)
if err == nil {
return self.ResetMarkedBaseCommit()
}
return err
})
doRebase := func(base string) error {
self.c.LogAction(self.c.Tr.Actions.RebaseBranch)
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error {
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
var err error
if baseCommit != "" {
err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(base, baseCommit)
} else {
err = self.c.Git().Rebase.RebaseBranch(base)
}
err = self.CheckMergeOrRebase(err)
if err == nil {
return self.ResetMarkedBaseCommit()
}
return err
})
}
if baseAmbiguous {
return self.baseBranchHelper.ShowPicker(checkedOutBranch, baseCandidates, doRebase)
}
return doRebase(baseBranch)
},
},
}
+27 -10
View File
@@ -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,
}
}
@@ -428,7 +431,7 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest
func (self *RefsHelper) MoveCommitsToNewBranch() error {
currentBranch := self.c.Model().Branches[0]
baseBranchRef, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(currentBranch, self.c.Model().MainBranches)
baseBranchRef, baseAmbiguous, baseCandidates, err := self.baseBranchHelper.ResolveBaseBranch(currentBranch)
if err != nil {
return err
}
@@ -477,11 +480,19 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error {
return nil
}
shortBaseBranchName := ShortBranchName(baseBranchRef)
baseBranchLabel := BaseBranchDisplayName(baseBranchRef)
if baseAmbiguous {
shortNames := lo.Map(baseCandidates, func(ref string, _ int) string {
return BaseBranchDisplayName(ref)
})
baseBranchLabel = utils.ResolvePlaceholderString(self.c.Tr.PickBaseBranchLabel,
map[string]string{"candidates": strings.Join(shortNames, ", ")},
)
}
prompt := utils.ResolvePlaceholderString(
self.c.Tr.MoveCommitsToNewBranchMenuPrompt,
map[string]string{
"baseBranchName": shortBaseBranchName,
"baseBranchName": baseBranchLabel,
},
)
return self.c.Menu(types.CreateMenuOptions{
@@ -489,11 +500,17 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error {
Prompt: prompt,
Items: []*types.MenuItem{
{
Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, shortBaseBranchName),
Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, baseBranchLabel),
OnPress: func() error {
return withNewBranchNamePrompt(shortBaseBranchName, func(newBranchName string) error {
return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, baseBranchRef)
})
moveOff := func(base string) error {
return withNewBranchNamePrompt(BaseBranchDisplayName(base), func(newBranchName string) error {
return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, base)
})
}
if baseAmbiguous {
return self.baseBranchHelper.ShowPicker(currentBranch, baseCandidates, moveOff)
}
return moveOff(baseBranchRef)
},
},
{
+18 -2
View File
@@ -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 += "↓"
}
}
}
+60
View File
@@ -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,
+6
View File
@@ -563,6 +563,9 @@ type TranslationSet struct {
ViewDivergenceFromUpstream string
ViewDivergenceFromBaseBranch string
CouldNotDetermineBaseBranch string
PickBaseBranchTitle string
PickBaseBranchPrompt string
PickBaseBranchLabel string
DivergenceSectionHeaderLocal string
DivergenceSectionHeaderRemote string
ViewUpstreamResetOptions string
@@ -1715,6 +1718,9 @@ func EnglishTranslationSet() *TranslationSet {
ViewDivergenceFromUpstream: "View divergence from upstream",
ViewDivergenceFromBaseBranch: "View divergence from base branch ({{.baseBranch}})",
CouldNotDetermineBaseBranch: "Couldn't determine base branch",
PickBaseBranchTitle: "Pick a base branch for {{.branchName}}",
PickBaseBranchPrompt: "More than one configured main branch is a candidate for this branch's base. Pick which one to treat as its base.",
PickBaseBranchLabel: "pick: {{.candidates}}",
DivergenceSectionHeaderLocal: "Local",
DivergenceSectionHeaderRemote: "Remote",
ViewUpstreamResetOptions: "Reset checked-out branch onto {{.upstream}}",
@@ -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()
+1 -1
View File
@@ -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"