diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index ac5555b3d..cd78a755b 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -285,6 +285,16 @@ func (self *BranchCommands) Merge(branchName string, variant MergeVariant) error return self.cmd.New(cmdArgs).Run() } +// Returns whether refName can be fast-forward merged into the current branch +func (self *BranchCommands) CanDoFastForwardMerge(refName string) bool { + cmdArgs := NewGitCmd("merge-base"). + Arg("--is-ancestor"). + Arg("HEAD", refName). + ToArgv() + err := self.cmd.New(cmdArgs).DontLog().Run() + return err == nil +} + // Only choose between non-empty, non-identical commands func (self *BranchCommands) allBranchesLogCandidates() []string { return lo.Uniq(lo.WithoutEmpty(self.UserConfig().Git.AllBranchesLogCmds)) diff --git a/pkg/commands/git_commands/config.go b/pkg/commands/git_commands/config.go index fa6570356..a9fd4e147 100644 --- a/pkg/commands/git_commands/config.go +++ b/pkg/commands/git_commands/config.go @@ -97,6 +97,10 @@ func (self *ConfigCommands) GetRebaseUpdateRefs() bool { return self.gitConfig.GetBool("rebase.updateRefs") } +func (self *ConfigCommands) GetMergeFF() string { + return self.gitConfig.Get("merge.ff") +} + func (self *ConfigCommands) DropConfigCache() { self.gitConfig.DropCache() } diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 3efa47799..7d4f0a96a 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -381,21 +381,86 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e return errors.New(self.c.Tr.CantMergeBranchIntoItself) } + wantFastForward, wantNonFastForward := self.fastForwardMergeUserPreference() + canFastForward := self.c.Git().Branch.CanDoFastForwardMerge(refName) + + var firstRegularMergeItem *types.MenuItem + var secondRegularMergeItem *types.MenuItem + var fastForwardMergeItem *types.MenuItem + + if !wantNonFastForward && (wantFastForward || canFastForward) { + firstRegularMergeItem = &types.MenuItem{ + Label: self.c.Tr.RegularMergeFastForward, + OnPress: self.RegularMerge(refName, git_commands.MERGE_VARIANT_REGULAR), + Key: 'm', + Tooltip: utils.ResolvePlaceholderString( + self.c.Tr.RegularMergeFastForwardTooltip, + map[string]string{ + "checkedOutBranch": checkedOutBranchName, + "selectedBranch": refName, + }, + ), + } + fastForwardMergeItem = firstRegularMergeItem + + secondRegularMergeItem = &types.MenuItem{ + Label: self.c.Tr.RegularMergeNonFastForward, + OnPress: self.RegularMerge(refName, git_commands.MERGE_VARIANT_NON_FAST_FORWARD), + Key: 'n', + Tooltip: utils.ResolvePlaceholderString( + self.c.Tr.RegularMergeNonFastForwardTooltip, + map[string]string{ + "checkedOutBranch": checkedOutBranchName, + "selectedBranch": refName, + }, + ), + } + } else { + firstRegularMergeItem = &types.MenuItem{ + Label: self.c.Tr.RegularMergeNonFastForward, + OnPress: self.RegularMerge(refName, git_commands.MERGE_VARIANT_REGULAR), + Key: 'm', + Tooltip: utils.ResolvePlaceholderString( + self.c.Tr.RegularMergeNonFastForwardTooltip, + map[string]string{ + "checkedOutBranch": checkedOutBranchName, + "selectedBranch": refName, + }, + ), + } + + secondRegularMergeItem = &types.MenuItem{ + Label: self.c.Tr.RegularMergeFastForward, + OnPress: self.RegularMerge(refName, git_commands.MERGE_VARIANT_FAST_FORWARD), + Key: 'f', + Tooltip: utils.ResolvePlaceholderString( + self.c.Tr.RegularMergeFastForwardTooltip, + map[string]string{ + "checkedOutBranch": checkedOutBranchName, + "selectedBranch": refName, + }, + ), + } + fastForwardMergeItem = secondRegularMergeItem + } + + if !canFastForward { + fastForwardMergeItem.DisabledReason = &types.DisabledReason{ + Text: utils.ResolvePlaceholderString( + self.c.Tr.CannotFastForwardMerge, + map[string]string{ + "checkedOutBranch": checkedOutBranchName, + "selectedBranch": refName, + }, + ), + } + } + return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.Merge, Items: []*types.MenuItem{ - { - Label: self.c.Tr.RegularMerge, - OnPress: self.RegularMerge(refName), - Key: 'm', - Tooltip: utils.ResolvePlaceholderString( - self.c.Tr.RegularMergeTooltip, - map[string]string{ - "checkedOutBranch": checkedOutBranchName, - "selectedBranch": refName, - }, - ), - }, + firstRegularMergeItem, + secondRegularMergeItem, { Label: self.c.Tr.SquashMergeUncommitted, OnPress: self.SquashMergeUncommitted(refName), @@ -423,10 +488,10 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e }) } -func (self *MergeAndRebaseHelper) RegularMerge(refName string) func() error { +func (self *MergeAndRebaseHelper) RegularMerge(refName string, variant git_commands.MergeVariant) func() error { return func() error { self.c.LogAction(self.c.Tr.Actions.Merge) - err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_REGULAR) + err := self.c.Git().Branch.Merge(refName, variant) return self.CheckMergeOrRebase(err) } } @@ -459,6 +524,31 @@ func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranch } } +// Returns wantsFastForward, wantsNonFastForward. These will never both be true, but they can both be false. +func (self *MergeAndRebaseHelper) fastForwardMergeUserPreference() (bool, bool) { + // Check user config first, because it takes precedence over git config + mergingArgs := self.c.UserConfig().Git.Merging.Args + if strings.Contains(mergingArgs, "--ff") { // also covers "--ff-only" + return true, false + } + + if strings.Contains(mergingArgs, "--no-ff") { + return false, true + } + + // Then check git config + mergeFfConfig := self.c.Git().Config.GetMergeFF() + if mergeFfConfig == "true" || mergeFfConfig == "only" { + return true, false + } + + if mergeFfConfig == "false" { + return false, true + } + + return false, false +} + func (self *MergeAndRebaseHelper) ResetMarkedBaseCommit() error { self.c.Modes().MarkedBaseCommit.Reset() self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index e40b71d8c..cb070b892 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -264,8 +264,11 @@ type TranslationSet struct { FocusMainView string Merge string MergeBranchTooltip string - RegularMerge string - RegularMergeTooltip string + RegularMergeFastForward string + RegularMergeFastForwardTooltip string + CannotFastForwardMerge string + RegularMergeNonFastForward string + RegularMergeNonFastForwardTooltip string SquashMergeUncommitted string SquashMergeUncommittedTooltip string SquashMergeCommitted string @@ -1351,8 +1354,11 @@ func EnglishTranslationSet() *TranslationSet { FocusMainView: "Focus main view", Merge: `Merge`, MergeBranchTooltip: "View options for merging the selected item into the current branch (regular merge, squash merge)", - RegularMerge: "Regular merge", - RegularMergeTooltip: "Merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}'.", + RegularMergeFastForward: "Regular merge (fast-forward)", + RegularMergeFastForwardTooltip: "Fast-forward '{{.checkedOutBranch}}' to '{{.selectedBranch}}' without creating a merge commit.", + CannotFastForwardMerge: "Cannot fast-forward '{{.checkedOutBranch}}' to '{{.selectedBranch}}'", + RegularMergeNonFastForward: "Regular merge (with merge commit)", + RegularMergeNonFastForwardTooltip: "Merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}', creating a merge commit.", SquashMergeUncommitted: "Squash merge and leave uncommitted", SquashMergeUncommittedTooltip: "Squash merge '{{.selectedBranch}}' into the working tree.", SquashMergeCommitted: "Squash merge and commit", diff --git a/pkg/integration/tests/branch/merge_fast_forward.go b/pkg/integration/tests/branch/merge_fast_forward.go new file mode 100644 index 000000000..707af1b3e --- /dev/null +++ b/pkg/integration/tests/branch/merge_fast_forward.go @@ -0,0 +1,68 @@ +package branch + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var MergeFastForward = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Merge a branch into another using fast-forward merge", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical" + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("original-branch"). + EmptyCommit("one"). + NewBranch("branch1"). + EmptyCommit("branch1"). + Checkout("original-branch"). + NewBranchFrom("branch2", "original-branch"). + EmptyCommit("branch2"). + Checkout("original-branch") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("original-branch").IsSelected(), + Contains("branch1"), + Contains("branch2"), + ). + SelectNextItem(). + Press(keys.Branches.MergeIntoCurrentBranch) + + t.ExpectPopup().Menu(). + Title(Equals("Merge")). + TopLines( + Contains("Regular merge (fast-forward)"), + Contains("Regular merge (with merge commit)"), + ). + Select(Contains("Regular merge (fast-forward)")). + Confirm() + + t.Views().Commits(). + Lines( + Contains("branch1").IsSelected(), + Contains("one"), + ) + + // Check that branch2 can't be merged using fast-forward + t.Views().Branches(). + Focus(). + NavigateToLine(Contains("branch2")). + Press(keys.Branches.MergeIntoCurrentBranch) + + t.ExpectPopup().Menu(). + Title(Equals("Merge")). + TopLines( + Contains("Regular merge (with merge commit)"), + Contains("Regular merge (fast-forward)"), + ). + Select(Contains("Regular merge (fast-forward)")). + Confirm() + + t.ExpectToast(Contains("Cannot fast-forward 'original-branch' to 'branch2'")) + }, +}) diff --git a/pkg/integration/tests/branch/merge_non_fast_forward.go b/pkg/integration/tests/branch/merge_non_fast_forward.go new file mode 100644 index 000000000..a1e90d049 --- /dev/null +++ b/pkg/integration/tests/branch/merge_non_fast_forward.go @@ -0,0 +1,76 @@ +package branch + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var MergeNonFastForward = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Merge a branch into another using non-fast-forward merge", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical" + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("original-branch"). + EmptyCommit("one"). + NewBranch("branch1"). + EmptyCommit("branch1"). + Checkout("original-branch"). + NewBranchFrom("branch2", "original-branch"). + EmptyCommit("branch2"). + Checkout("original-branch") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("original-branch").IsSelected(), + Contains("branch1"), + Contains("branch2"), + ). + SelectNextItem(). + Press(keys.Branches.MergeIntoCurrentBranch) + + t.ExpectPopup().Menu(). + Title(Equals("Merge")). + TopLines( + Contains("Regular merge (fast-forward)"), + Contains("Regular merge (with merge commit)"), + ). + Select(Contains("Regular merge (with merge commit)")). + Confirm() + + t.Views().Commits(). + Lines( + Contains("⏣─╮ Merge branch 'branch1' into original-branch").IsSelected(), + Contains("│ ◯ * branch1"), + Contains("◯─╯ one"), + ) + + // Check that branch2 shows the non-fast-forward option first + t.Views().Branches(). + Focus(). + NavigateToLine(Contains("branch2")). + Press(keys.Branches.MergeIntoCurrentBranch) + + t.ExpectPopup().Menu(). + Title(Equals("Merge")). + TopLines( + Contains("Regular merge (with merge commit)"), + Contains("Regular merge (fast-forward)"), + ). + Select(Contains("Regular merge (with merge commit)")). + Confirm() + + t.Views().Commits(). + Lines( + Contains("⏣─╮ Merge branch 'branch2' into original-branch").IsSelected(), + Contains("│ ◯ * branch2"), + Contains("⏣─│─╮ Merge branch 'branch1' into original-branch"), + Contains("│ │ ◯ * branch1"), + Contains("◯─┴─╯ one"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index c08d68a13..da21d1f0d 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -48,6 +48,8 @@ var tests = []*components.IntegrationTest{ branch.DeleteRemoteBranchWithDifferentName, branch.DeleteWhileFiltering, branch.DetachedHead, + branch.MergeFastForward, + branch.MergeNonFastForward, branch.MoveCommitsToNewBranchFromBaseBranch, branch.MoveCommitsToNewBranchFromMainBranch, branch.MoveCommitsToNewBranchKeepStacked, diff --git a/pkg/integration/tests/ui/mode_specific_keybinding_suggestions.go b/pkg/integration/tests/ui/mode_specific_keybinding_suggestions.go index 5cd412146..d64a22a38 100644 --- a/pkg/integration/tests/ui/mode_specific_keybinding_suggestions.go +++ b/pkg/integration/tests/ui/mode_specific_keybinding_suggestions.go @@ -103,7 +103,7 @@ var ModeSpecificKeybindingSuggestions = NewIntegrationTest(NewIntegrationTestArg Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Merge")). - Select(Contains("Regular merge")). + Select(Contains("Regular merge (with merge commit)")). Confirm() t.Common().AcknowledgeConflicts()