mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-29 10:36:45 -04:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e74cc0268 | ||
|
|
cee2de60ba | ||
|
|
d688fa9319 | ||
|
|
272235925b | ||
|
|
2efaa38124 | ||
|
|
df138a4c72 | ||
|
|
acc15a6e43 | ||
|
|
dcf0532020 | ||
|
|
f59f993d1c | ||
|
|
c9eb915279 | ||
|
|
98a421091f | ||
|
|
672a37031d | ||
|
|
84bd2b6fe1 | ||
|
|
5a9e8c6af9 | ||
|
|
28cfcaced2 | ||
|
|
dd03884087 | ||
|
|
a0e51da643 |
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 += "↓"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user