From 76948f82c11060c7e80ee7d4042f19567a613af5 Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Sun, 19 Oct 2025 21:56:17 +0900 Subject: [PATCH 1/9] Add `synchronize` event to the hooks of "Check Required Labels" --- .github/workflows/check-required-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-required-label.yml b/.github/workflows/check-required-label.yml index 830060fd008..8a2090da7a1 100644 --- a/.github/workflows/check-required-label.yml +++ b/.github/workflows/check-required-label.yml @@ -2,7 +2,7 @@ name: Check Required Labels on: pull_request: - types: [opened, labeled, unlabeled] + types: [opened, labeled, unlabeled, synchronize] jobs: check-required-label: From 4e0194d8f7ac9a8bf837907d6e636790b26f8411 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Oct 2025 14:57:59 +0200 Subject: [PATCH 2/9] Replace MergeOpts struct with MergeVariant enum - Squash and FastForwardOnly are mutually exclusive, and instead of asserting this at runtime, model the API so that they can't be passed together. - FastForwardOnly is unused, so remove it; however, we are going to need --ff and --no-ff in the next commit, so add those instead. - Instead of putting the enum into the MergeOpts struct, replace the struct by the enum. We can reintroduce the struct when we add more arguments, but for now it's an unnecessary indirection. --- pkg/commands/git_commands/branch.go | 35 +++++++++++++------ pkg/commands/git_commands/branch_test.go | 30 +++++++++++----- .../helpers/merge_and_rebase_helper.go | 6 ++-- 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index 13abbd69744..ac5555b3d13 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -250,20 +250,35 @@ func (self *BranchCommands) Rename(oldName string, newName string) error { return self.cmd.New(cmdArgs).Run() } -type MergeOpts struct { - FastForwardOnly bool - Squash bool -} +type MergeVariant int + +const ( + MERGE_VARIANT_REGULAR MergeVariant = iota + MERGE_VARIANT_FAST_FORWARD + MERGE_VARIANT_NON_FAST_FORWARD + MERGE_VARIANT_SQUASH +) + +func (self *BranchCommands) Merge(branchName string, variant MergeVariant) error { + extraArgs := func() []string { + switch variant { + case MERGE_VARIANT_REGULAR: + return []string{} + case MERGE_VARIANT_FAST_FORWARD: + return []string{"--ff"} + case MERGE_VARIANT_NON_FAST_FORWARD: + return []string{"--no-ff"} + case MERGE_VARIANT_SQUASH: + return []string{"--squash", "--ff"} + } + + panic("shouldn't get here") + }() -func (self *BranchCommands) Merge(branchName string, opts MergeOpts) error { - if opts.Squash && opts.FastForwardOnly { - panic("Squash and FastForwardOnly can't both be true") - } cmdArgs := NewGitCmd("merge"). Arg("--no-edit"). Arg(strings.Fields(self.UserConfig().Git.Merging.Args)...). - ArgIf(opts.FastForwardOnly, "--ff-only"). - ArgIf(opts.Squash, "--squash", "--ff"). + Arg(extraArgs...). Arg(branchName). ToArgv() diff --git a/pkg/commands/git_commands/branch_test.go b/pkg/commands/git_commands/branch_test.go index 37ad79613de..a0c0096b945 100644 --- a/pkg/commands/git_commands/branch_test.go +++ b/pkg/commands/git_commands/branch_test.go @@ -122,14 +122,14 @@ func TestBranchMerge(t *testing.T) { scenarios := []struct { testName string userConfig *config.UserConfig - opts MergeOpts + variant MergeVariant branchName string expected []string }{ { testName: "basic", userConfig: &config.UserConfig{}, - opts: MergeOpts{}, + variant: MERGE_VARIANT_REGULAR, branchName: "mybranch", expected: []string{"merge", "--no-edit", "mybranch"}, }, @@ -142,7 +142,7 @@ func TestBranchMerge(t *testing.T) { }, }, }, - opts: MergeOpts{}, + variant: MERGE_VARIANT_REGULAR, branchName: "mybranch", expected: []string{"merge", "--no-edit", "--merging-args", "mybranch"}, }, @@ -155,16 +155,30 @@ func TestBranchMerge(t *testing.T) { }, }, }, - opts: MergeOpts{}, + variant: MERGE_VARIANT_REGULAR, branchName: "mybranch", expected: []string{"merge", "--no-edit", "--arg1", "--arg2", "mybranch"}, }, { - testName: "fast forward only", + testName: "fast-forward merge", userConfig: &config.UserConfig{}, - opts: MergeOpts{FastForwardOnly: true}, + variant: MERGE_VARIANT_FAST_FORWARD, branchName: "mybranch", - expected: []string{"merge", "--no-edit", "--ff-only", "mybranch"}, + expected: []string{"merge", "--no-edit", "--ff", "mybranch"}, + }, + { + testName: "non-fast-forward merge", + userConfig: &config.UserConfig{}, + variant: MERGE_VARIANT_NON_FAST_FORWARD, + branchName: "mybranch", + expected: []string{"merge", "--no-edit", "--no-ff", "mybranch"}, + }, + { + testName: "squash merge", + userConfig: &config.UserConfig{}, + variant: MERGE_VARIANT_SQUASH, + branchName: "mybranch", + expected: []string{"merge", "--no-edit", "--squash", "--ff", "mybranch"}, }, } @@ -174,7 +188,7 @@ func TestBranchMerge(t *testing.T) { ExpectGitArgs(s.expected, "", nil) instance := buildBranchCommands(commonDeps{runner: runner, userConfig: s.userConfig}) - assert.NoError(t, instance.Merge(s.branchName, s.opts)) + assert.NoError(t, instance.Merge(s.branchName, s.variant)) runner.CheckForMissingCalls() }) } diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 88166ec4951..caddc77ceac 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -426,7 +426,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e func (self *MergeAndRebaseHelper) RegularMerge(refName string) func() error { return func() error { self.c.LogAction(self.c.Tr.Actions.Merge) - err := self.c.Git().Branch.Merge(refName, git_commands.MergeOpts{}) + err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_REGULAR) return self.CheckMergeOrRebase(err) } } @@ -434,7 +434,7 @@ func (self *MergeAndRebaseHelper) RegularMerge(refName string) func() error { func (self *MergeAndRebaseHelper) SquashMergeUncommitted(refName string) func() error { return func() error { self.c.LogAction(self.c.Tr.Actions.SquashMerge) - err := self.c.Git().Branch.Merge(refName, git_commands.MergeOpts{Squash: true}) + err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH) return self.CheckMergeOrRebase(err) } } @@ -442,7 +442,7 @@ func (self *MergeAndRebaseHelper) SquashMergeUncommitted(refName string) func() func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranchName string) func() error { return func() error { self.c.LogAction(self.c.Tr.Actions.SquashMerge) - err := self.c.Git().Branch.Merge(refName, git_commands.MergeOpts{Squash: true}) + err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH) if err = self.CheckMergeOrRebase(err); err != nil { return err } From afbef402587b6b625eb72e6a9d9822c3a2dfcffe Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Oct 2025 17:36:07 +0200 Subject: [PATCH 3/9] Reorder texts so that they are grouped together, and ordered in a sensible way --- pkg/i18n/english.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 6c474c0b049..6e21bbbd352 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -23,11 +23,6 @@ type TranslationSet struct { StagedChanges string StagingTitle string MergingTitle string - SquashMergeUncommittedTitle string - SquashMergeCommittedTitle string - SquashMergeUncommitted string - SquashMergeCommitted string - RegularMergeTooltip string NormalTitle string LogTitle string LogXOfYTitle string @@ -268,8 +263,13 @@ type TranslationSet struct { RefreshFiles string FocusMainView string Merge string - RegularMerge string MergeBranchTooltip string + RegularMerge string + RegularMergeTooltip string + SquashMergeUncommittedTitle string + SquashMergeUncommitted string + SquashMergeCommittedTitle string + SquashMergeCommitted string ConfirmQuit string SwitchRepo string AllBranchesLogGraph string @@ -1108,8 +1108,6 @@ func EnglishTranslationSet() *TranslationSet { EasterEgg: "Easter egg", UnstagedChanges: "Unstaged changes", StagedChanges: "Staged changes", - SquashMergeUncommittedTitle: "Squash merge and leave uncommitted", - SquashMergeCommittedTitle: "Squash merge and commit", StagingTitle: "Main panel (staging)", MergingTitle: "Main panel (merging)", NormalTitle: "Main panel (normal)", @@ -1352,8 +1350,13 @@ func EnglishTranslationSet() *TranslationSet { RefreshFiles: `Refresh files`, FocusMainView: "Focus main view", Merge: `Merge`, - RegularMerge: "Regular 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}}'.", + SquashMergeUncommittedTitle: "Squash merge and leave uncommitted", + SquashMergeUncommitted: "Squash merge '{{.selectedBranch}}' into the working tree.", + SquashMergeCommittedTitle: "Squash merge and commit", + SquashMergeCommitted: "Squash merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}' as a single commit.", ConfirmQuit: `Are you sure you want to quit?`, SwitchRepo: `Switch to a recent repo`, AllBranchesLogGraph: `Show/cycle all branch logs`, @@ -1438,9 +1441,6 @@ func EnglishTranslationSet() *TranslationSet { InteractiveRebaseTooltip: "Begin an interactive rebase with a break at the start, so you can update the TODO commits before continuing.", RebaseOntoBaseBranchTooltip: "Rebase the checked out branch onto its base branch (i.e. the closest main branch).", MustSelectTodoCommits: "When rebasing, this action only works on a selection of TODO commits.", - SquashMergeUncommitted: "Squash merge '{{.selectedBranch}}' into the working tree.", - SquashMergeCommitted: "Squash merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}' as a single commit.", - RegularMergeTooltip: "Merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}'.", FwdNoUpstream: "Cannot fast-forward a branch with no upstream", FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally", FwdCommitsToPush: "Cannot fast-forward a branch with commits to push", From c88381b9ce6a509f9552982ed8ba97c7137022ad Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Oct 2025 17:37:23 +0200 Subject: [PATCH 4/9] Rename texts to be consistent with our conventions --- .../controllers/helpers/merge_and_rebase_helper.go | 8 ++++---- pkg/i18n/english.go | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index caddc77ceac..3efa4779958 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -397,22 +397,22 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e ), }, { - Label: self.c.Tr.SquashMergeUncommittedTitle, + Label: self.c.Tr.SquashMergeUncommitted, OnPress: self.SquashMergeUncommitted(refName), Key: 's', Tooltip: utils.ResolvePlaceholderString( - self.c.Tr.SquashMergeUncommitted, + self.c.Tr.SquashMergeUncommittedTooltip, map[string]string{ "selectedBranch": refName, }, ), }, { - Label: self.c.Tr.SquashMergeCommittedTitle, + Label: self.c.Tr.SquashMergeCommitted, OnPress: self.SquashMergeCommitted(refName, checkedOutBranchName), Key: 'S', Tooltip: utils.ResolvePlaceholderString( - self.c.Tr.SquashMergeCommitted, + self.c.Tr.SquashMergeCommittedTooltip, map[string]string{ "checkedOutBranch": checkedOutBranchName, "selectedBranch": refName, diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 6e21bbbd352..e40b71d8ca7 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -266,10 +266,10 @@ type TranslationSet struct { MergeBranchTooltip string RegularMerge string RegularMergeTooltip string - SquashMergeUncommittedTitle string SquashMergeUncommitted string - SquashMergeCommittedTitle string + SquashMergeUncommittedTooltip string SquashMergeCommitted string + SquashMergeCommittedTooltip string ConfirmQuit string SwitchRepo string AllBranchesLogGraph string @@ -1353,10 +1353,10 @@ func EnglishTranslationSet() *TranslationSet { MergeBranchTooltip: "View options for merging the selected item into the current branch (regular merge, squash merge)", RegularMerge: "Regular merge", RegularMergeTooltip: "Merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}'.", - SquashMergeUncommittedTitle: "Squash merge and leave uncommitted", - SquashMergeUncommitted: "Squash merge '{{.selectedBranch}}' into the working tree.", - SquashMergeCommittedTitle: "Squash merge and commit", - SquashMergeCommitted: "Squash merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}' as a single commit.", + SquashMergeUncommitted: "Squash merge and leave uncommitted", + SquashMergeUncommittedTooltip: "Squash merge '{{.selectedBranch}}' into the working tree.", + SquashMergeCommitted: "Squash merge and commit", + SquashMergeCommittedTooltip: "Squash merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}' as a single commit.", ConfirmQuit: `Are you sure you want to quit?`, SwitchRepo: `Switch to a recent repo`, AllBranchesLogGraph: `Show/cycle all branch logs`, From 62854026a3130a37b35a1fd97ba334c9045d7b70 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Oct 2025 14:59:04 +0200 Subject: [PATCH 5/9] Add no-ff merge option This will put whatever git's default merge variant is as the first menu item, and add a second item which is the opposite (no-ff if the default is ff, and vice versa). If users prefer to always have the same option first no matter whether it's applicable, they can make ff always appear first by setting git's "merge.ff" config to "true" or "only", or by setting lazygit's "git.merging.args" config to "--ff" or "--ff-only"; if they want no-ff to appear first, they can do that by setting git's "merge.ff" config to "false", or by setting lazygit's "git.merging.args" config to "--no-ff". Which of these they choose depends on whether they want the config to also apply to other git clients including the cli, or only to lazygit. --- pkg/commands/git_commands/branch.go | 10 ++ pkg/commands/git_commands/config.go | 4 + .../helpers/merge_and_rebase_helper.go | 118 +++++++++++++++--- pkg/i18n/english.go | 14 ++- .../tests/branch/merge_fast_forward.go | 68 ++++++++++ .../tests/branch/merge_non_fast_forward.go | 76 +++++++++++ pkg/integration/tests/test_list.go | 2 + .../mode_specific_keybinding_suggestions.go | 2 +- 8 files changed, 275 insertions(+), 19 deletions(-) create mode 100644 pkg/integration/tests/branch/merge_fast_forward.go create mode 100644 pkg/integration/tests/branch/merge_non_fast_forward.go diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index ac5555b3d13..cd78a755b7f 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 fa657035693..a9fd4e14772 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 3efa4779958..7d4f0a96a7d 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 e40b71d8ca7..cb070b89207 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 00000000000..707af1b3e8a --- /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 00000000000..a1e90d0497a --- /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 c08d68a1376..da21d1f0d3c 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 5cd412146f2..d64a22a38cc 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() From 76452a0bcc3978acda1166ae5e40e598db55ca9c Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Mon, 22 Sep 2025 22:36:53 +0900 Subject: [PATCH 6/9] Add `ignore` directive in go.mod This can be used by go tools such as gofumpt. --- go.mod | 3 +++ 1 file changed, 3 insertions(+) diff --git a/go.mod b/go.mod index a425a279731..81f7d8df325 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,9 @@ module github.com/jesseduffield/lazygit go 1.25.0 +// This is necessary to ignore test files when executing gofumpt. +ignore ./test + require ( dario.cat/mergo v1.0.1 github.com/adrg/xdg v0.4.0 From 65c27dd8ce965338447dcde06ac8d0565bff1e4a Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Sun, 19 Oct 2025 19:23:02 +0200 Subject: [PATCH 7/9] Remove workaround for "make format" When a new enough gofumpt version is used (v0.9.0 or later), it suports the `ignore` directive that we just added, so the workaround is no longer needed. Co-authored-by: Stefan Haller --- Makefile | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 5132211298c..38dc118cb82 100644 --- a/Makefile +++ b/Makefile @@ -34,11 +34,9 @@ test: unit-test integration-test-all generate: go generate ./... -# If you execute `gofumpt -l -w .`, it will format all Go files in the current directory, including `test/_results/*` files. -# We pass only Git-tracked Go files to gofumpt because we don't want to format the test results or get errors from it. .PHONY: format format: - git ls-files '*.go' ':!vendor' | xargs gofumpt -l -w + gofumpt -l -w . .PHONY: lint lint: From beb05d4a61d2374b4b41551f354c3d46644d9c15 Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Sun, 19 Oct 2025 19:16:23 +0200 Subject: [PATCH 8/9] Fix makeAtomic in branches_test When replacing the naked return with a `return result`, the linter starts to complain about "return copies lock value: sync/atomic.Int32 contains sync/atomic.noCopy". I suspect this is also a problem when using a naked return, and the linter just doesn't catch it in that case. Either way, it's better to use a pointer to ensure that the atomic is not copied. Co-authored-by: Stefan Haller --- pkg/gui/presentation/branches_test.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/gui/presentation/branches_test.go b/pkg/gui/presentation/branches_test.go index 347802a6ee5..a73c7aa56dc 100644 --- a/pkg/gui/presentation/branches_test.go +++ b/pkg/gui/presentation/branches_test.go @@ -16,9 +16,10 @@ import ( "github.com/xo/terminfo" ) -func makeAtomic(v int32) (result atomic.Int32) { +func makeAtomic(v int32) *atomic.Int32 { + var result atomic.Int32 result.Store(v) - return //nolint: nakedret + return &result } func Test_getBranchDisplayStrings(t *testing.T) { @@ -109,7 +110,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { branch: &models.Branch{ Name: "branch_name", Recency: "1m", - BehindBaseBranch: makeAtomic(2), + BehindBaseBranch: *makeAtomic(2), }, itemOperation: types.ItemOperationNone, fullDescription: false, @@ -126,7 +127,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { UpstreamRemote: "origin", AheadForPull: "0", BehindForPull: "0", - BehindBaseBranch: makeAtomic(2), + BehindBaseBranch: *makeAtomic(2), }, itemOperation: types.ItemOperationNone, fullDescription: false, @@ -143,7 +144,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { UpstreamRemote: "origin", AheadForPull: "3", BehindForPull: "5", - BehindBaseBranch: makeAtomic(2), + BehindBaseBranch: *makeAtomic(2), }, itemOperation: types.ItemOperationNone, fullDescription: false, @@ -247,7 +248,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { UpstreamRemote: "origin", AheadForPull: "3", BehindForPull: "5", - BehindBaseBranch: makeAtomic(4), + BehindBaseBranch: *makeAtomic(4), }, itemOperation: types.ItemOperationNone, fullDescription: false, From 64bcc72e458123e301637912c9e631061fb3799d Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Mon, 22 Sep 2025 22:42:58 +0900 Subject: [PATCH 9/9] Specify return value where named return value is used After [v0.9.0](https://github.com/mvdan/gofumpt/releases/tag/v0.9.0), gofumpt prohibits "naked return" for the sake of clarity. This makes more readable when "named return value" is used. For more infomation for "prohibition of naked return": https://github.com/mvdan/gofumpt/issues/285. --- pkg/commands/oscommands/copy.go | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/pkg/commands/oscommands/copy.go b/pkg/commands/oscommands/copy.go index c6d83e23b00..2df96d05729 100644 --- a/pkg/commands/oscommands/copy.go +++ b/pkg/commands/oscommands/copy.go @@ -35,16 +35,16 @@ import ( // destination file exists, all it's contents will be replaced by the contents // of the source file. The file mode will be copied from the source and // the copied data is synced/flushed to stable storage. -func CopyFile(src, dst string) (err error) { +func CopyFile(src, dst string) error { in, err := os.Open(src) if err != nil { - return //nolint: nakedret + return err } defer in.Close() out, err := os.Create(dst) if err != nil { - return //nolint: nakedret + return err } defer func() { if e := out.Close(); e != nil { @@ -54,30 +54,30 @@ func CopyFile(src, dst string) (err error) { _, err = io.Copy(out, in) if err != nil { - return //nolint: nakedret + return err } err = out.Sync() if err != nil { - return //nolint: nakedret + return err } si, err := os.Stat(src) if err != nil { - return //nolint: nakedret + return err } err = os.Chmod(dst, si.Mode()) if err != nil { - return //nolint: nakedret + return err } - return //nolint: nakedret + return err } // CopyDir recursively copies a directory tree, attempting to preserve permissions. // Source directory must exist. If destination already exists we'll clobber it. // Symlinks are ignored and skipped. -func CopyDir(src string, dst string) (err error) { +func CopyDir(src string, dst string) error { src = filepath.Clean(src) dst = filepath.Clean(dst) @@ -91,7 +91,7 @@ func CopyDir(src string, dst string) (err error) { _, err = os.Stat(dst) if err != nil && !os.IsNotExist(err) { - return //nolint: nakedret + return err } if err == nil { // it exists so let's remove it @@ -102,12 +102,12 @@ func CopyDir(src string, dst string) (err error) { err = os.MkdirAll(dst, si.Mode()) if err != nil { - return //nolint: nakedret + return err } entries, err := os.ReadDir(src) if err != nil { - return //nolint: nakedret + return err } for _, entry := range entries { @@ -117,13 +117,13 @@ func CopyDir(src string, dst string) (err error) { if entry.IsDir() { err = CopyDir(srcPath, dstPath) if err != nil { - return //nolint: nakedret + return err } } else { var info os.FileInfo info, err = entry.Info() if err != nil { - return //nolint: nakedret + return err } // Skip symlinks. @@ -133,10 +133,10 @@ func CopyDir(src string, dst string) (err error) { err = CopyFile(srcPath, dstPath) if err != nil { - return //nolint: nakedret + return err } } } - return //nolint: nakedret + return err }