From a7fcae2ca1ff66feacd45bcd236246177b0dc22b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:01:01 +0100 Subject: [PATCH 1/7] Add a test for squash merging while there are staged changes If the checked-out branch can be fast-forwarded to the selected branch, "git merge --squash" doesn't refuse to run while there are staged changes, unlike a merge that can't be fast-forwarded. It adds the changes of the branch to the ones that are already staged. "Squash merge and commit" then commits the staged changes along with the squashed ones, without the user noticing. Co-Authored-By: Claude Opus 5.5 --- .../squash_merge_with_staged_changes.go | 64 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 65 insertions(+) create mode 100644 pkg/integration/tests/branch/squash_merge_with_staged_changes.go diff --git a/pkg/integration/tests/branch/squash_merge_with_staged_changes.go b/pkg/integration/tests/branch/squash_merge_with_staged_changes.go new file mode 100644 index 000000000..4778101b0 --- /dev/null +++ b/pkg/integration/tests/branch/squash_merge_with_staged_changes.go @@ -0,0 +1,64 @@ +package branch + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SquashMergeWithStagedChanges = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Try to squash merge a branch while there are staged changes", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.NewBranch("original-branch"). + EmptyCommit("one"). + NewBranch("feature-branch"). + CreateFileAndAdd("feature-file", "content"). + Commit("feature"). + Checkout("original-branch"). + CreateFileAndAdd("staged-file", "content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("original-branch").IsSelected(), + Contains("feature-branch"), + ). + SelectNextItem(). + Press(keys.Branches.MergeIntoCurrentBranch) + + t.ExpectPopup().Menu(). + Title(Equals("Merge")). + Select(Contains("Squash merge and commit")). + Confirm() + + /* EXPECTED: + t.ExpectToast(Equals("Disabled: Cannot squash merge while there are staged changes")) + + t.ExpectPopup().Menu(). + Title(Equals("Merge")). + Select(Contains("Squash merge and leave uncommitted")). + Confirm() + + t.ExpectToast(Equals("Disabled: Cannot squash merge while there are staged changes")) + ACTUAL: */ + t.Views().Commits(). + Focus(). + Lines( + Contains("Squash merge feature-branch into original-branch"), + Contains("one"), + ). + NavigateToLine(Contains("Squash merge feature-branch into original-branch")). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ /"), + Contains("feature-file"), + Contains("staged-file"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index f2369036f..a20ab7f6b 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -110,6 +110,7 @@ var tests = []*components.IntegrationTest{ branch.SortRemoteBranches, branch.SortRemoteBranchesInStackOrder, branch.SquashMerge, + branch.SquashMergeWithStagedChanges, branch.Suggestions, branch.UnsetUpstream, cherry_pick.CherryPick, From acb54f73d1ae7a91f37d5ea0071cad096ed4d24e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:02:09 +0100 Subject: [PATCH 2/7] Disable squash merging while there are staged changes Git refuses to merge a branch while there are staged changes, unless the merge is a fast-forward. A fast-forward keeps the staged changes as they are. A squash merge that git can fast-forward adds the changes of the branch to them, though, and "Squash merge and commit" then commits them all. Disable both squash merge items in the merge menu while there are staged changes, so that squash merges behave like other merges. We will rely on this when we let the user abort a squash merge. Aborting resets the staged changes, so they must all come from the merge. Co-Authored-By: Claude Opus 5.5 --- .../helpers/merge_and_rebase_helper.go | 22 ++++++++++++++----- pkg/i18n/english.go | 2 ++ .../squash_merge_with_staged_changes.go | 18 --------------- 3 files changed, 18 insertions(+), 24 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 4ce378bfe..222067991 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -611,15 +611,24 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e } } + // Git refuses to merge while there are staged changes, but not if it can + // fast-forward the squash merge. It would add the changes of the branch to + // the staged ones then, and we couldn't tell them apart anymore. + var squashMergeDisabledReason *types.DisabledReason + if AnyStagedFiles(self.c.Model().Files) { + squashMergeDisabledReason = &types.DisabledReason{Text: self.c.Tr.CantSquashMergeWithStagedChanges} + } + return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.Merge, Items: []*types.MenuItem{ firstRegularMergeItem, secondRegularMergeItem, { - Label: self.c.Tr.SquashMergeUncommitted, - OnPress: self.SquashMergeUncommitted(refName), - Keys: menuKey('s'), + Label: self.c.Tr.SquashMergeUncommitted, + OnPress: self.SquashMergeUncommitted(refName), + DisabledReason: squashMergeDisabledReason, + Keys: menuKey('s'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.SquashMergeUncommittedTooltip, map[string]string{ @@ -628,9 +637,10 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e ), }, { - Label: self.c.Tr.SquashMergeCommitted, - OnPress: self.SquashMergeCommitted(refName, checkedOutBranchName), - Keys: menuKey('S'), + Label: self.c.Tr.SquashMergeCommitted, + OnPress: self.SquashMergeCommitted(refName, checkedOutBranchName), + DisabledReason: squashMergeDisabledReason, + Keys: menuKey('S'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.SquashMergeCommittedTooltip, map[string]string{ diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 7a5a6d3d8..184000577 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -304,6 +304,7 @@ type TranslationSet struct { SquashMergeUncommittedTooltip string SquashMergeCommitted string SquashMergeCommittedTooltip string + CantSquashMergeWithStagedChanges string ConfirmQuit string SwitchRepo string AllBranchesLogGraph string @@ -1533,6 +1534,7 @@ func EnglishTranslationSet() *TranslationSet { SquashMergeUncommittedTooltip: "Squash merge '{{.selectedBranch}}' into the working tree.", SquashMergeCommitted: "Squash merge and commit", SquashMergeCommittedTooltip: "Squash merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}' as a single commit.", + CantSquashMergeWithStagedChanges: "Cannot squash merge while there are staged changes", ConfirmQuit: `Are you sure you want to quit?`, SwitchRepo: `Switch to a recent repo`, AllBranchesLogGraph: `Show/cycle all branch logs`, diff --git a/pkg/integration/tests/branch/squash_merge_with_staged_changes.go b/pkg/integration/tests/branch/squash_merge_with_staged_changes.go index 4778101b0..0e2b70413 100644 --- a/pkg/integration/tests/branch/squash_merge_with_staged_changes.go +++ b/pkg/integration/tests/branch/squash_merge_with_staged_changes.go @@ -34,7 +34,6 @@ var SquashMergeWithStagedChanges = NewIntegrationTest(NewIntegrationTestArgs{ Select(Contains("Squash merge and commit")). Confirm() - /* EXPECTED: t.ExpectToast(Equals("Disabled: Cannot squash merge while there are staged changes")) t.ExpectPopup().Menu(). @@ -43,22 +42,5 @@ var SquashMergeWithStagedChanges = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() t.ExpectToast(Equals("Disabled: Cannot squash merge while there are staged changes")) - ACTUAL: */ - t.Views().Commits(). - Focus(). - Lines( - Contains("Squash merge feature-branch into original-branch"), - Contains("one"), - ). - NavigateToLine(Contains("Squash merge feature-branch into original-branch")). - PressEnter() - - t.Views().CommitFiles(). - IsFocused(). - Lines( - Equals("▼ /"), - Contains("feature-file"), - Contains("staged-file"), - ) }, }) From c7002e5e52e16febcbbac31f844d3bd5e8ddfcd6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:05:44 +0100 Subject: [PATCH 3/7] Discard edits on top of staged changes when aborting a merge or cherry-pick If a merge, cherry-pick, revert or "git am" stops with conflicts, users often edit the files that git changed, for example to fix the build. If they then decide to give up and abort, git fails with "Entry 'file' not uptodate. Cannot merge." It keeps unstaged changes when aborting, but it can't reset the staged changes of a file underneath them. "git am --abort" even stops halfway, after it has reset the conflicted files. Git refuses to start any of these operations if there are staged changes, or if a file that it has to change has unstaged changes. So unstaged changes on top of staged ones were made after git stopped. Discard them before aborting, but ask the user first, listing the files. A rebase doesn't need this, because "git rebase --abort" discards all changes. Co-Authored-By: Claude Opus 5.5 --- pkg/commands/git_commands/working_tree.go | 5 ++ .../helpers/merge_and_rebase_helper.go | 89 +++++++++++++++---- pkg/i18n/english.go | 2 + ...t_cherry_pick_after_editing_picked_file.go | 58 ++++++++++++ .../abort_merge_after_editing_merged_file.go | 59 ++++++++++++ pkg/integration/tests/test_list.go | 2 + 6 files changed, 199 insertions(+), 16 deletions(-) create mode 100644 pkg/integration/tests/cherry_pick/abort_cherry_pick_after_editing_picked_file.go create mode 100644 pkg/integration/tests/conflicts/abort_merge_after_editing_merged_file.go diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index db68d694f..14faaa3e0 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -361,6 +361,11 @@ func (self *WorkingTreeCommands) DiscardUnstagedFileChanges(file *models.File) e return self.cmd.New(cmdArgs).Run() } +// DiscardUnstagedChanges discards the unstaged changes of the given files +func (self *WorkingTreeCommands) DiscardUnstagedChanges(paths []string) error { + return runGitCmdOnPaths("checkout", paths, self.cmd) +} + // Escapes special characters in a filename for gitignore and exclude files, and prepends `/` func escapeFilename(filename string) string { re := regexp.MustCompile(`^[!#]|[\[\]*]`) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 222067991..2807860d9 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -64,6 +64,9 @@ func (self *MergeAndRebaseHelper) CreateRebaseOptionsMenu() error { return &types.MenuItem{ Label: row.option, OnPress: func() error { + if row.option == REBASE_OPTION_ABORT { + return self.abortMergeOrRebase(false) + } return self.genericMergeCommand(row.option) }, Keys: row.keys, @@ -89,8 +92,9 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { // non-subprocess path runs on a worker with a waiting status. // // showWaitingStatus is false only for the recursive auto-skip in -// CheckMergeOrRebaseWithRefreshOptions, which already runs on a worker, so it -// must not spin up a second waiting status. calledFromWorker is used only by the +// CheckMergeOrRebaseWithRefreshOptions and for aborting after discarding +// changes in abortMergeOrRebase; both already run on a worker, so they must not +// spin up a second waiting status. calledFromWorker is used only by the // subprocess path below: it's true for that recursive worker skip and false for // genericMergeCommand's UI-thread invocation, so the post-action refresh picks // RefreshFromWorker vs Refresh correctly. @@ -141,7 +145,8 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa // runAction always ends up on a worker: either the waiting status below spins // one up, or we're the recursive auto-skip reached from - // CheckMergeOrRebaseWithRefreshOptions, which already runs on one. + // CheckMergeOrRebaseWithRefreshOptions or the abort in abortMergeOrRebase, + // which already run on one. runAction := func() error { result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) return self.CheckMergeOrRebaseWithRefreshOptions(result, @@ -305,7 +310,7 @@ func (self *MergeAndRebaseHelper) PromptForConflictHandling() error { Label: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationMenuItem, map[string]string{"operation": operation}), OnPress: func() error { - return self.genericMergeCommand(REBASE_OPTION_ABORT) + return self.abortMergeOrRebase(false) }, Keys: menuKey('a'), }, @@ -315,19 +320,71 @@ func (self *MergeAndRebaseHelper) PromptForConflictHandling() error { } func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error { - // prompt user to confirm that they want to abort, then do it - placeholders := map[string]string{ - "operation": self.c.Git().Status.WorkingTreeState().OperationName(self.c.Tr), - } - self.c.Confirm(types.ConfirmOpts{ - Title: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationTitle, placeholders), - Prompt: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationPrompt, placeholders), - HandleConfirm: func() error { - return self.genericMergeCommand(REBASE_OPTION_ABORT) - }, - }) + return self.abortMergeOrRebase(true) +} - return nil +// abortMergeOrRebase aborts the operation in progress. If alwaysConfirm is +// false, it asks the user to confirm only if it has to discard changes before +// git can abort. +func (self *MergeAndRebaseHelper) abortMergeOrRebase(alwaysConfirm bool) error { + status := self.c.Git().Status.WorkingTreeState() + placeholders := map[string]string{ + "operation": status.OperationName(self.c.Tr), + } + + pathsToDiscard := pathsToDiscardBeforeAbort(status, self.c.Model().Files, self.c.Model().Submodules) + if len(pathsToDiscard) > 0 { + self.c.Confirm(types.ConfirmOpts{ + Title: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationTitle, placeholders), + Prompt: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationDiscardsChangesPrompt, placeholders) + + "\n\n- " + strings.Join(pathsToDiscard, "\n- "), + HandleConfirm: func() error { + return self.c.WithWaitingStatus(status.Title(self.c.Tr), func(gocui.Task) error { + self.c.LogAction(self.c.Tr.Actions.DiscardAllUnstagedChangesInFile) + if err := self.c.Git().WorkingTree.DiscardUnstagedChanges(pathsToDiscard); err != nil { + return err + } + return self.genericMergeCommandImpl(REBASE_OPTION_ABORT, false, true) + }) + }, + }) + return nil + } + + if alwaysConfirm { + self.c.Confirm(types.ConfirmOpts{ + Title: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationTitle, placeholders), + Prompt: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationPrompt, placeholders), + HandleConfirm: func() error { + return self.genericMergeCommand(REBASE_OPTION_ABORT) + }, + }) + return nil + } + + return self.genericMergeCommand(REBASE_OPTION_ABORT) +} + +// pathsToDiscardBeforeAbort returns the files whose unstaged changes we have to +// discard before git can abort the operation in progress. +// +// Git refuses to start a merge, cherry-pick, revert or "git am" if there are +// staged changes, or if a file that it has to change has unstaged changes. So +// if a file has staged changes during the operation, git staged them (or the +// user did after git stopped), and any unstaged changes on top of them were +// made after git stopped, for example to fix the build. Git's abort command +// keeps unstaged changes, but it fails if they are on top of staged changes +// that it has to reset. A rebase is different. It refuses to start if there are +// any changes, and its abort command discards all changes. +func pathsToDiscardBeforeAbort(status models.WorkingTreeState, files []*models.File, submoduleConfigs []*models.SubmoduleConfig) []string { + if status.Effective() == models.WORKING_TREE_STATE_REBASING { + return nil + } + + return lo.FilterMap(files, func(file *models.File, _ int) (string, bool) { + return file.Path, file.HasStagedChanges && file.HasUnstagedChanges && !file.HasMergeConflicts && + !file.IsSubmodule(submoduleConfigs) + }) } // PromptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 184000577..3d4689d8a 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -892,6 +892,7 @@ type TranslationSet struct { GitCommandFailed string AbortOperationTitle string AbortOperationPrompt string + AbortOperationDiscardsChangesPrompt string OpenLogMenu string OpenLogMenuTooltip string LogMenuTitle string @@ -2126,6 +2127,7 @@ func EnglishTranslationSet() *TranslationSet { GitCommandFailed: "Git command failed. Check command log for details (open with %s)", AbortOperationTitle: "Abort {{.operation}}", AbortOperationPrompt: "Are you sure you want to abort {{.operation}}?", + AbortOperationDiscardsChangesPrompt: "Aborting {{.operation}} also discards the unstaged changes of these files. Are you sure?", OpenLogMenu: "View log options", OpenLogMenuTooltip: "View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph.", LogMenuTitle: "Commit Log Options", diff --git a/pkg/integration/tests/cherry_pick/abort_cherry_pick_after_editing_picked_file.go b/pkg/integration/tests/cherry_pick/abort_cherry_pick_after_editing_picked_file.go new file mode 100644 index 000000000..11363d80c --- /dev/null +++ b/pkg/integration/tests/cherry_pick/abort_cherry_pick_after_editing_picked_file.go @@ -0,0 +1,58 @@ +package cherry_pick + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var AbortCherryPickAfterEditingPickedFile = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Abort a cherry-pick that stopped with conflicts after editing a file that it picked without conflicts", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell. + CreateFileAndAdd("conflicting-file", "original\n"). + CreateFileAndAdd("picked-file", "original\n"). + Commit("original"). + NewBranch("feature"). + UpdateFileAndAdd("conflicting-file", "feature\n"). + UpdateFileAndAdd("picked-file", "feature\n"). + Commit("feature change"). + Checkout("master"). + UpdateFileAndAdd("conflicting-file", "master\n"). + Commit("master change"). + RunCommandExpectError([]string{"git", "cherry-pick", "feature"}). + UpdateFile("picked-file", "feature\nfix the build\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Status().Content(Equals("(cherry-picking) repo → master")) + + t.Views().Files(). + IsFocused(). + Lines( + Equals("UU conflicting-file").IsSelected(), + ) + + t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu) + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick options")). + Select(Contains("abort")). + Confirm() + + t.ExpectPopup().Confirmation(). + Title(Equals("Abort the cherry-pick")). + Content(Equals("Aborting the cherry-pick also discards the unstaged changes of these files. Are you sure?\n\n- picked-file")). + Confirm() + + t.Views().Status().Content(Equals("repo → master")) + + t.Views().Files().IsEmpty() + + t.Views().Commits(). + Lines( + Contains("master change"), + Contains("original"), + ) + }, +}) diff --git a/pkg/integration/tests/conflicts/abort_merge_after_editing_merged_file.go b/pkg/integration/tests/conflicts/abort_merge_after_editing_merged_file.go new file mode 100644 index 000000000..bd40103d4 --- /dev/null +++ b/pkg/integration/tests/conflicts/abort_merge_after_editing_merged_file.go @@ -0,0 +1,59 @@ +package conflicts + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var AbortMergeAfterEditingMergedFile = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Abort a merge that stopped with conflicts after editing a file that it merged without conflicts", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell. + CreateFileAndAdd("conflicting-file", "original\n"). + CreateFileAndAdd("merged-file", "original\n"). + CreateFileAndAdd("unrelated-file", "original\n"). + Commit("original"). + NewBranch("feature"). + UpdateFileAndAdd("conflicting-file", "feature\n"). + UpdateFileAndAdd("merged-file", "feature\n"). + Commit("feature change"). + Checkout("master"). + UpdateFileAndAdd("conflicting-file", "master\n"). + Commit("master change"). + UpdateFile("unrelated-file", "unrelated change\n"). + RunCommandExpectError([]string{"git", "merge", "--no-edit", "feature"}). + UpdateFile("merged-file", "feature\nfix the build\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Status().Content(Equals("(merging) repo → master")) + + t.Views().Files(). + IsFocused(). + Lines( + Equals("UU conflicting-file").IsSelected(), + ) + + t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu) + t.ExpectPopup().Menu(). + Title(Equals("Merge options")). + Select(Contains("abort")). + Confirm() + + t.ExpectPopup().Confirmation(). + Title(Equals("Abort the merge")). + Content(Equals("Aborting the merge also discards the unstaged changes of these files. Are you sure?\n\n- merged-file")). + Confirm() + + t.Views().Status().Content(Equals("repo → master")) + + t.Views().Files(). + Lines( + Equals(" M unrelated-file"), + ) + + t.FileSystem().FileContent("merged-file", Equals("original\n")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a20ab7f6b..b474b99a4 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -113,6 +113,7 @@ var tests = []*components.IntegrationTest{ branch.SquashMergeWithStagedChanges, branch.Suggestions, branch.UnsetUpstream, + cherry_pick.AbortCherryPickAfterEditingPickedFile, cherry_pick.CherryPick, cherry_pick.CherryPickAcrossWorktrees, cherry_pick.CherryPickCommitThatBecomesEmpty, @@ -199,6 +200,7 @@ var tests = []*components.IntegrationTest{ config.NegativeRefspec, config.RemoteNamedStar, config.SidePanelsInPerRepoConfig, + conflicts.AbortMergeAfterEditingMergedFile, conflicts.ConflictMarkerSizeNotAutoStaged, conflicts.ConflictMarkerSizeResolve, conflicts.ContinuePromptDismissedWhenResolvedExternally, From 9942101b3e173011aef53d95b9f5274d6e6ace9a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:07:53 +0100 Subject: [PATCH 4/7] Identify the operation to continue or abort by its working tree state GenericMergeOrRebaseAction takes the name of the git command whose --continue, --skip or --abort option it runs. We are about to show a squash merge as an operation in progress, and it has no such command. "git merge --continue" and "git merge --abort" refuse to run without MERGE_HEAD, and a squash merge doesn't write it. Pass the working tree state instead, so that GenericMergeOrRebaseAction can decide which commands to run. Co-Authored-By: Claude Opus 5.5 --- pkg/commands/git_commands/rebase.go | 39 +++++++++++-------- pkg/commands/models/working_tree_state.go | 6 ++- .../helpers/merge_and_rebase_helper.go | 5 +-- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index 675f50c8c..caac7b6ef 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -474,25 +474,29 @@ func (self *RebaseCommands) RebaseBranchFromBaseCommit(targetBranchName string, }).Run() } -func (self *RebaseCommands) GenericMergeOrRebaseActionCmdObj(commandType string, command string) *oscommands.CmdObj { - cmdArgs := NewGitCmd(commandType).Arg("--" + command).ToArgv() +// GenericMergeOrRebaseActionCmdObj returns the command that continues, skips or +// aborts the given operation; command is "continue", "skip" or "abort". +func (self *RebaseCommands) GenericMergeOrRebaseActionCmdObj(operation models.EffectiveWorkingTreeState, command string) *oscommands.CmdObj { + cmdArgs := NewGitCmd(operation.CommandName()).Arg("--" + command).ToArgv() return self.cmd.New(cmdArgs) } func (self *RebaseCommands) ContinueRebase() error { - return self.GenericMergeOrRebaseAction("rebase", "continue") + return self.GenericMergeOrRebaseAction(models.WORKING_TREE_STATE_REBASING, "continue") } func (self *RebaseCommands) AbortRebase() error { - return self.GenericMergeOrRebaseAction("rebase", "abort") + return self.GenericMergeOrRebaseAction(models.WORKING_TREE_STATE_REBASING, "abort") } -// GenericMerge takes a commandType of "merge" or "rebase" and a command of "abort", "skip" or "continue" -// By default we skip the editor in the case where a commit will be made -func (self *RebaseCommands) GenericMergeOrRebaseAction(commandType string, command string) error { +// GenericMergeOrRebaseAction continues, skips or aborts the given operation; +// command is "continue", "skip" or "abort". If this makes a commit, we skip the +// editor. +func (self *RebaseCommands) GenericMergeOrRebaseAction(operation models.EffectiveWorkingTreeState, command string) error { undoRepeatingTodo := func() {} - if command == "continue" && (commandType == "cherry-pick" || commandType == "revert") { + if command == "continue" && + (operation == models.WORKING_TREE_STATE_CHERRY_PICKING || operation == models.WORKING_TREE_STATE_REVERTING) { var err error undoRepeatingTodo, err = repeatCurrentSequencerTodo( self.cmd, self.repoPaths.WorktreeGitDirPath(), self.config.GetCoreCommentChar()) @@ -501,23 +505,24 @@ func (self *RebaseCommands) GenericMergeOrRebaseAction(commandType string, comma } } - if commandType == "rebase" && command == "continue" { + if operation == models.WORKING_TREE_STATE_REBASING && command == "continue" { if err := self.commitPickThatRebaseStoppedAt(); err != nil { return err } } - cmdObj := skipEditor(self.GenericMergeOrRebaseActionCmdObj(commandType, command)) + cmdObj := skipEditor(self.GenericMergeOrRebaseActionCmdObj(operation, command)) if command == "continue" || command == "skip" { - switch commandType { - case "rebase": + switch operation { + case models.WORKING_TREE_STATE_REBASING: cmdObj = self.resumeAfterLockError(cmdObj) - case "cherry-pick", "revert": + case models.WORKING_TREE_STATE_CHERRY_PICKING, models.WORKING_TREE_STATE_REVERTING: cmdObj = retryContinueOrSkipAfterLockError(cmdObj, filepath.Join(self.repoPaths.WorktreeGitDirPath(), "sequencer", "todo")) - case "am": + case models.WORKING_TREE_STATE_APPLYING_PATCHES: cmdObj = retryContinueOrSkipAfterLockError(cmdObj, filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-apply", "next")) + case models.WORKING_TREE_STATE_MERGING, models.WORKING_TREE_STATE_NONE: } } @@ -533,7 +538,7 @@ func (self *RebaseCommands) GenericMergeOrRebaseAction(commandType string, comma // sometimes we need to do a sequence of things in a rebase but the user needs to // fix merge conflicts along the way. When this happens we queue up the next step // so that after the next successful rebase continue we can continue from where we left off - if commandType == "rebase" && command == "continue" && self.onSuccessfulContinue != nil { + if operation == models.WORKING_TREE_STATE_REBASING && command == "continue" && self.onSuccessfulContinue != nil { f := self.onSuccessfulContinue self.onSuccessfulContinue = nil return f() @@ -554,7 +559,7 @@ func (self *RebaseCommands) commitPickThatRebaseStoppedAt() error { return nil } - return skipEditor(self.GenericMergeOrRebaseActionCmdObj("cherry-pick", "continue")).Run() + return skipEditor(self.GenericMergeOrRebaseActionCmdObj(models.WORKING_TREE_STATE_CHERRY_PICKING, "continue")).Run() } // resumeAfterLockError makes cmdObj, a command that starts or continues a @@ -563,7 +568,7 @@ func (self *RebaseCommands) commitPickThatRebaseStoppedAt() error { func (self *RebaseCommands) resumeAfterLockError(cmdObj *oscommands.CmdObj) *oscommands.CmdObj { return resumeRebaseAfterLockError(cmdObj, self.repoPaths.WorktreeGitDirPath(), self.config.GetCoreCommentChar(), func() *oscommands.CmdObj { - return skipEditor(self.GenericMergeOrRebaseActionCmdObj("rebase", "continue")) + return skipEditor(self.GenericMergeOrRebaseActionCmdObj(models.WORKING_TREE_STATE_REBASING, "continue")) }) } diff --git a/pkg/commands/models/working_tree_state.go b/pkg/commands/models/working_tree_state.go index 2207d3a77..8d951965e 100644 --- a/pkg/commands/models/working_tree_state.go +++ b/pkg/commands/models/working_tree_state.go @@ -117,14 +117,16 @@ func (self WorkingTreeState) OperationName(tr *i18n.TranslationSet) string { }[self.Effective()] } -func (self WorkingTreeState) CommandName() string { +// CommandName returns the git command whose --continue, --skip and --abort +// options continue, skip or abort the operation +func (self EffectiveWorkingTreeState) CommandName() string { return map[EffectiveWorkingTreeState]string{ WORKING_TREE_STATE_REBASING: "rebase", WORKING_TREE_STATE_MERGING: "merge", WORKING_TREE_STATE_CHERRY_PICKING: "cherry-pick", WORKING_TREE_STATE_REVERTING: "revert", WORKING_TREE_STATE_APPLYING_PATCHES: "am", - }[self.Effective()] + }[self] } func (self WorkingTreeState) CanShowTodos() bool { diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 2807860d9..72b93fa98 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -121,7 +121,6 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa } } - commandType := status.CommandName() selectHeadCommitOnSuccess := command == REBASE_OPTION_CONTINUE && effectiveStatus == models.WORKING_TREE_STATE_MERGING @@ -135,7 +134,7 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa if needsSubprocess { // TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction - success, err := self.c.RunSubprocess(self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command)) + success, err := self.c.RunSubprocess(self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(effectiveStatus, command)) self.refreshAfterMergeOrRebase(types.RefreshOptions{ CommitSelection: commitSelectionAfterMerge(success && selectHeadCommitOnSuccess), }, calledFromWorker) @@ -148,7 +147,7 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa // CheckMergeOrRebaseWithRefreshOptions or the abort in abortMergeOrRebase, // which already run on one. runAction := func() error { - result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) + result := self.c.Git().Rebase.GenericMergeOrRebaseAction(effectiveStatus, command) return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{ CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess), From c7c514fa4421f0a2bc9c0e2919cd82d5556e81a0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:11:51 +0100 Subject: [PATCH 5/7] Let the user continue or abort a squash merge If a squash merge stops with conflicts, lazygit doesn't show a merge in progress, because "git merge --squash" doesn't write MERGE_HEAD. After resolving the conflicts, users have to commit the changes themselves, and type the message that "Squash merge and commit" would have used. To give up, they have to discard the changes. The "Abort the merge" item of the conflicts menu fails with "You are currently neither rebasing nor merging". "git merge --squash" writes SQUASH_MSG, a message for committing the changes. Git removes it when the changes are committed, reset or stashed, and when switching branches. Show a merge in progress while the file exists. Continue it with "git commit", and abort it with "git reset --merge", like "git merge --abort" does. Write the configured squashMergeMessage into SQUASH_MSG, so that continuing uses it. Do this for "Squash merge and leave uncommitted" too, so that both variants make the same commit. A merge in progress is now shown after this variant until the user commits the changes. We don't offer to continue it when its conflicts are resolved, though, because the user asked us not to commit. Select the new commit after "Squash merge and commit", like after other merges. Co-Authored-By: Claude Opus 5.5 --- pkg/commands/git_commands/branch.go | 19 +++++ pkg/commands/git_commands/rebase.go | 22 +++++- pkg/commands/git_commands/status.go | 10 +++ pkg/commands/models/working_tree_state.go | 23 ++++-- .../helpers/merge_and_rebase_helper.go | 61 ++++++++------- pkg/integration/tests/branch/squash_merge.go | 2 + .../squash_merge_abort_after_conflicts.go | 52 +++++++++++++ .../squash_merge_continue_after_conflicts.go | 70 +++++++++++++++++ ...squash_merge_uncommitted_with_conflicts.go | 75 +++++++++++++++++++ pkg/integration/tests/test_list.go | 3 + 10 files changed, 304 insertions(+), 33 deletions(-) create mode 100644 pkg/integration/tests/branch/squash_merge_abort_after_conflicts.go create mode 100644 pkg/integration/tests/branch/squash_merge_continue_after_conflicts.go create mode 100644 pkg/integration/tests/branch/squash_merge_uncommitted_with_conflicts.go diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index 19dba12fe..fb32baa74 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -2,6 +2,7 @@ package git_commands import ( "fmt" + "path/filepath" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" @@ -285,6 +286,24 @@ func (self *BranchCommands) Merge(branchName string, variant MergeVariant) error return self.cmd.New(cmdArgs).Run() } +// SquashMerge stages the changes of the given branch, and makes message the +// message for committing them. "git merge --squash" suggests a log of the +// branch's commits in SQUASH_MSG; "git commit" uses this file. +func (self *BranchCommands) SquashMerge(branchName string, message string) error { + mergeErr := self.Merge(branchName, MERGE_VARIANT_SQUASH) + + // If the merge failed without writing SQUASH_MSG, it didn't start. Writing + // the file would make it look like a squash merge is in progress. + squashMsgPath := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "SQUASH_MSG") + if exists, _ := self.os.FileExists(squashMsgPath); exists { + if err := self.os.CreateFileWithContent(squashMsgPath, message+"\n"); err != nil { + return err + } + } + + return mergeErr +} + // Fast-forwards the branch that is checked out in the given worktree to the // given ref. Fails if that can't be done without a merge commit. Pass empty // strings for the worktree to use the current one. diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index caac7b6ef..b0c9fe077 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -477,11 +477,31 @@ func (self *RebaseCommands) RebaseBranchFromBaseCommit(targetBranchName string, // GenericMergeOrRebaseActionCmdObj returns the command that continues, skips or // aborts the given operation; command is "continue", "skip" or "abort". func (self *RebaseCommands) GenericMergeOrRebaseActionCmdObj(operation models.EffectiveWorkingTreeState, command string) *oscommands.CmdObj { + if operation == models.WORKING_TREE_STATE_SQUASH_MERGING { + return self.cmd.New(squashMergeActionArgs(command)) + } + cmdArgs := NewGitCmd(operation.CommandName()).Arg("--" + command).ToArgv() return self.cmd.New(cmdArgs) } +// squashMergeActionArgs returns the arguments of the command that continues or +// aborts a squash merge. "git merge --continue" and "git merge --abort" refuse +// to run without MERGE_HEAD, which a squash merge doesn't write. "git commit" +// commits the changes with the message in SQUASH_MSG. "git merge --abort" runs +// "git reset --merge" too. +func squashMergeActionArgs(command string) []string { + switch command { + case "continue": + return NewGitCmd("commit").ToArgv() + case "abort": + return NewGitCmd("reset").Arg("--merge").ToArgv() + } + + panic("can't " + command + " a squash merge") +} + func (self *RebaseCommands) ContinueRebase() error { return self.GenericMergeOrRebaseAction(models.WORKING_TREE_STATE_REBASING, "continue") } @@ -522,7 +542,7 @@ func (self *RebaseCommands) GenericMergeOrRebaseAction(operation models.Effectiv case models.WORKING_TREE_STATE_APPLYING_PATCHES: cmdObj = retryContinueOrSkipAfterLockError(cmdObj, filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-apply", "next")) - case models.WORKING_TREE_STATE_MERGING, models.WORKING_TREE_STATE_NONE: + case models.WORKING_TREE_STATE_MERGING, models.WORKING_TREE_STATE_SQUASH_MERGING, models.WORKING_TREE_STATE_NONE: } } diff --git a/pkg/commands/git_commands/status.go b/pkg/commands/git_commands/status.go index f65990359..8f3b4920d 100644 --- a/pkg/commands/git_commands/status.go +++ b/pkg/commands/git_commands/status.go @@ -31,6 +31,7 @@ func (self *StatusCommands) WorkingTreeState() models.WorkingTreeState { result.CherryPicking, _ = self.IsInCherryPick() result.Reverting, _ = self.IsInRevert() result.ApplyingPatches, _ = self.IsApplyingPatches() + result.SquashMerging, _ = self.IsInSquashMerge() return result } @@ -63,6 +64,15 @@ func (self *StatusCommands) IsInMergeState() (bool, error) { return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "MERGE_HEAD")) } +// IsInSquashMerge tells whether "git merge --squash" has staged changes that +// haven't been committed yet. "git merge --squash" writes SQUASH_MSG, a message +// for committing them, but no MERGE_HEAD. "git commit" uses the message and +// removes the file; so do "git reset" without paths, switching branches, and +// "git stash". +func (self *StatusCommands) IsInSquashMerge() (bool, error) { + return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "SQUASH_MSG")) +} + func (self *StatusCommands) IsInCherryPick() (bool, error) { hasCherryPickHead, err := self.hasCherryPickHead() if err != nil || hasCherryPickHead { diff --git a/pkg/commands/models/working_tree_state.go b/pkg/commands/models/working_tree_state.go index 8d951965e..0bd0f860b 100644 --- a/pkg/commands/models/working_tree_state.go +++ b/pkg/commands/models/working_tree_state.go @@ -4,9 +4,9 @@ import "github.com/jesseduffield/lazygit/pkg/i18n" // The state of the working tree. Several of these can be true at once. // In particular, the concrete multi-state combinations that can occur in -// practice are Rebasing+CherryPicking, Rebasing+Reverting, and -// Rebasing+ApplyingPatches. Theoretically, I guess Rebasing+Merging could also -// happen, but it probably won't in practice. +// practice are Rebasing+CherryPicking, Rebasing+Reverting, +// Rebasing+ApplyingPatches, and Rebasing+SquashMerging. Theoretically, I guess +// Rebasing+Merging could also happen, but it probably won't in practice. type WorkingTreeState struct { Rebasing bool Merging bool @@ -14,10 +14,13 @@ type WorkingTreeState struct { Reverting bool // git am ApplyingPatches bool + // git merge --squash, until the changes are committed + SquashMerging bool } func (self WorkingTreeState) Any() bool { - return self.Rebasing || self.Merging || self.CherryPicking || self.Reverting || self.ApplyingPatches + return self.Rebasing || self.Merging || self.CherryPicking || self.Reverting || self.ApplyingPatches || + self.SquashMerging } func (self WorkingTreeState) None() bool { @@ -34,6 +37,7 @@ const ( WORKING_TREE_STATE_CHERRY_PICKING WORKING_TREE_STATE_REVERTING WORKING_TREE_STATE_APPLYING_PATCHES + WORKING_TREE_STATE_SQUASH_MERGING ) // Effective returns the "current" state; if several states are true at once, @@ -56,6 +60,9 @@ func (self WorkingTreeState) Effective() EffectiveWorkingTreeState { if self.Merging { return WORKING_TREE_STATE_MERGING } + if self.SquashMerging { + return WORKING_TREE_STATE_SQUASH_MERGING + } if self.ApplyingPatches { return WORKING_TREE_STATE_APPLYING_PATCHES } @@ -69,6 +76,7 @@ func (self WorkingTreeState) Title(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ WORKING_TREE_STATE_REBASING: tr.RebasingStatus, WORKING_TREE_STATE_MERGING: tr.MergingStatus, + WORKING_TREE_STATE_SQUASH_MERGING: tr.MergingStatus, WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickingStatus, WORKING_TREE_STATE_REVERTING: tr.RevertingStatus, WORKING_TREE_STATE_APPLYING_PATCHES: tr.ApplyingPatchesStatus, @@ -79,6 +87,7 @@ func (self WorkingTreeState) LowerCaseTitle(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ WORKING_TREE_STATE_REBASING: tr.LowercaseRebasingStatus, WORKING_TREE_STATE_MERGING: tr.LowercaseMergingStatus, + WORKING_TREE_STATE_SQUASH_MERGING: tr.LowercaseMergingStatus, WORKING_TREE_STATE_CHERRY_PICKING: tr.LowercaseCherryPickingStatus, WORKING_TREE_STATE_REVERTING: tr.LowercaseRevertingStatus, WORKING_TREE_STATE_APPLYING_PATCHES: tr.LowercaseApplyingPatchesStatus, @@ -89,6 +98,7 @@ func (self WorkingTreeState) OptionsMenuTitle(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ WORKING_TREE_STATE_REBASING: tr.RebaseOptionsTitle, WORKING_TREE_STATE_MERGING: tr.MergeOptionsTitle, + WORKING_TREE_STATE_SQUASH_MERGING: tr.MergeOptionsTitle, WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickOptionsTitle, WORKING_TREE_STATE_REVERTING: tr.RevertOptionsTitle, WORKING_TREE_STATE_APPLYING_PATCHES: tr.ApplyPatchesOptionsTitle, @@ -99,6 +109,7 @@ func (self WorkingTreeState) OptionsMapTitle(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ WORKING_TREE_STATE_REBASING: tr.ViewRebaseOptions, WORKING_TREE_STATE_MERGING: tr.ViewMergeOptions, + WORKING_TREE_STATE_SQUASH_MERGING: tr.ViewMergeOptions, WORKING_TREE_STATE_CHERRY_PICKING: tr.ViewCherryPickOptions, WORKING_TREE_STATE_REVERTING: tr.ViewRevertOptions, WORKING_TREE_STATE_APPLYING_PATCHES: tr.ViewApplyPatchesOptions, @@ -111,6 +122,7 @@ func (self WorkingTreeState) OperationName(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ WORKING_TREE_STATE_REBASING: tr.RebaseOperationName, WORKING_TREE_STATE_MERGING: tr.MergeOperationName, + WORKING_TREE_STATE_SQUASH_MERGING: tr.MergeOperationName, WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickOperationName, WORKING_TREE_STATE_REVERTING: tr.RevertOperationName, WORKING_TREE_STATE_APPLYING_PATCHES: tr.ApplyPatchesOperationName, @@ -118,7 +130,8 @@ func (self WorkingTreeState) OperationName(tr *i18n.TranslationSet) string { } // CommandName returns the git command whose --continue, --skip and --abort -// options continue, skip or abort the operation +// options continue, skip or abort the operation. A squash merge has no such +// command. func (self EffectiveWorkingTreeState) CommandName() string { return map[EffectiveWorkingTreeState]string{ WORKING_TREE_STATE_REBASING: "rebase", diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 72b93fa98..19903a85e 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -121,13 +121,14 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa } } - selectHeadCommitOnSuccess := command == REBASE_OPTION_CONTINUE && - effectiveStatus == models.WORKING_TREE_STATE_MERGING + isMerging := effectiveStatus == models.WORKING_TREE_STATE_MERGING || + effectiveStatus == models.WORKING_TREE_STATE_SQUASH_MERGING + selectHeadCommitOnSuccess := command == REBASE_OPTION_CONTINUE && isMerging // we should end up with a command like 'git merge --continue' // it's impossible for a rebase to require a commit so we'll use a subprocess only if it's a merge - needsSubprocess := (effectiveStatus == models.WORKING_TREE_STATE_MERGING && command != REBASE_OPTION_ABORT && self.c.UserConfig().Git.Merging.ManualCommit) || + needsSubprocess := (isMerging && command != REBASE_OPTION_ABORT && self.c.UserConfig().Git.Merging.ManualCommit) || // but we'll also use a subprocess if we have exec todos; those are likely to be lengthy build // tasks whose output the user will want to see in the terminal (effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos(calledFromWorker)) @@ -368,13 +369,15 @@ func (self *MergeAndRebaseHelper) abortMergeOrRebase(alwaysConfirm bool) error { // discard before git can abort the operation in progress. // // Git refuses to start a merge, cherry-pick, revert or "git am" if there are -// staged changes, or if a file that it has to change has unstaged changes. So -// if a file has staged changes during the operation, git staged them (or the -// user did after git stopped), and any unstaged changes on top of them were -// made after git stopped, for example to fix the build. Git's abort command -// keeps unstaged changes, but it fails if they are on top of staged changes -// that it has to reset. A rebase is different. It refuses to start if there are -// any changes, and its abort command discards all changes. +// staged changes, or if a file that it has to change has unstaged changes. It +// does start a squash merge that it can fast-forward while there are staged +// changes, but we don't offer squash merges then. Therefore, if a file has +// staged changes during the operation, git staged them (or the user did after +// git stopped), and any unstaged changes on top of them were made after git +// stopped, for example to fix the build. Git's abort command keeps unstaged +// changes, but it fails if they are on top of staged changes that it has to +// reset. A rebase is different. It refuses to start if there are any changes, +// and its abort command discards all changes. func pathsToDiscardBeforeAbort(status models.WorkingTreeState, files []*models.File, submoduleConfigs []*models.SubmoduleConfig) []string { if status.Effective() == models.WORKING_TREE_STATE_REBASING { return nil @@ -682,7 +685,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e secondRegularMergeItem, { Label: self.c.Tr.SquashMergeUncommitted, - OnPress: self.SquashMergeUncommitted(refName), + OnPress: self.SquashMergeUncommitted(refName, checkedOutBranchName), DisabledReason: squashMergeDisabledReason, Keys: menuKey('s'), Tooltip: utils.ResolvePlaceholderString( @@ -719,12 +722,18 @@ func (self *MergeAndRebaseHelper) RegularMerge(refName string, variant git_comma } } -func (self *MergeAndRebaseHelper) SquashMergeUncommitted(refName string) func() error { +func (self *MergeAndRebaseHelper) SquashMergeUncommitted(refName, checkedOutBranchName string) func() error { return func() error { self.c.LogAction(self.c.Tr.Actions.SquashMerge) return self.c.WithWaitingStatus(self.c.Tr.MergingStatus, func(gocui.Task) error { - err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH) - return self.CheckMergeOrRebase(err) + err := self.c.Git().Branch.SquashMerge(refName, self.squashMergeMessage(refName, checkedOutBranchName)) + err = self.CheckMergeOrRebase(err) + + // The user asked us not to commit the changes, so don't offer to + // once they have resolved the conflicts + self.c.State().GetRepoState().SetMergeOrRebaseStartedInLazygit(false) + + return err }) } } @@ -733,24 +742,22 @@ func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranch return func() error { self.c.LogAction(self.c.Tr.Actions.SquashMerge) return self.c.WithWaitingStatus(self.c.Tr.MergingStatus, func(gocui.Task) error { - err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH) - if err = self.CheckMergeOrRebase(err); err != nil { - return err + err := self.c.Git().Branch.SquashMerge(refName, self.squashMergeMessage(refName, checkedOutBranchName)) + if err == nil { + err = self.c.Git().Rebase.GenericMergeOrRebaseAction(models.WORKING_TREE_STATE_SQUASH_MERGING, REBASE_OPTION_CONTINUE) } - message := utils.ResolvePlaceholderString(self.c.UserConfig().Git.Merging.SquashMergeMessage, map[string]string{ - "selectedRef": refName, - "currentBranch": checkedOutBranchName, - }) - err = self.c.Git().Commit.CommitCmdObj(message, "", false).Run() - if err != nil { - return err - } - self.c.RefreshFromWorker(types.RefreshOptions{}) - return nil + return self.CheckMergeOrRebaseAndSelectHeadCommit(err) }) } } +func (self *MergeAndRebaseHelper) squashMergeMessage(refName, checkedOutBranchName string) string { + return utils.ResolvePlaceholderString(self.c.UserConfig().Git.Merging.SquashMergeMessage, map[string]string{ + "selectedRef": refName, + "currentBranch": checkedOutBranchName, + }) +} + // 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 diff --git a/pkg/integration/tests/branch/squash_merge.go b/pkg/integration/tests/branch/squash_merge.go index 509073cbe..416308920 100644 --- a/pkg/integration/tests/branch/squash_merge.go +++ b/pkg/integration/tests/branch/squash_merge.go @@ -60,5 +60,7 @@ var SquashMerge = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files().Focus().Lines( Contains("work"), ) + + t.Views().Status().Content(Equals("(merging) repo → original-branch")) }, }) diff --git a/pkg/integration/tests/branch/squash_merge_abort_after_conflicts.go b/pkg/integration/tests/branch/squash_merge_abort_after_conflicts.go new file mode 100644 index 000000000..e0872493f --- /dev/null +++ b/pkg/integration/tests/branch/squash_merge_abort_after_conflicts.go @@ -0,0 +1,52 @@ +package branch + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +var SquashMergeAbortAfterConflicts = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Squash merge and commit a branch, and abort when there are conflicts", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "recency" + }, + SetupRepo: func(shell *Shell) { + shared.MergeConflictsSetup(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("first-change-branch").IsSelected(), + Contains("second-change-branch"), + Contains("original-branch"), + ). + SelectNextItem(). + Press(keys.Branches.MergeIntoCurrentBranch) + + t.ExpectPopup().Menu(). + Title(Equals("Merge")). + Select(Contains("Squash merge and commit")). + Confirm() + + t.ExpectPopup().Menu(). + Title(Equals("Conflicts!")). + Select(Contains("Abort the merge")). + Confirm() + + t.Views().Status().Content(Equals("repo → first-change-branch")) + + t.Views().Files().IsEmpty() + + t.Views().Commits(). + TopLines( + Contains("first change"), + Contains("original"), + ) + + t.FileSystem().PathNotPresent(".git/SQUASH_MSG") + }, +}) diff --git a/pkg/integration/tests/branch/squash_merge_continue_after_conflicts.go b/pkg/integration/tests/branch/squash_merge_continue_after_conflicts.go new file mode 100644 index 000000000..1b6a32fab --- /dev/null +++ b/pkg/integration/tests/branch/squash_merge_continue_after_conflicts.go @@ -0,0 +1,70 @@ +package branch + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +var SquashMergeContinueAfterConflicts = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Squash merge and commit a branch, and continue after resolving the conflicts", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "recency" + }, + SetupRepo: func(shell *Shell) { + shared.MergeConflictsSetup(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("first-change-branch").IsSelected(), + Contains("second-change-branch"), + Contains("original-branch"), + ). + SelectNextItem(). + Press(keys.Branches.MergeIntoCurrentBranch) + + t.ExpectPopup().Menu(). + Title(Equals("Merge")). + Select(Contains("Squash merge and commit")). + Confirm() + + t.ExpectPopup().Menu(). + Title(Equals("Conflicts!")). + Select(Contains("View conflicts")). + Confirm() + + t.Views().Status().Content(Equals("(merging) repo → first-change-branch")) + + t.Views().Files(). + IsFocused(). + Lines( + Contains("UU file").IsSelected(), + ). + Tap(func() { + t.Shell().UpdateFile("file", "resolved content") + }). + Press(keys.Universal.Refresh) + + t.Common().ContinueOnConflictsResolved("merge") + + t.Views().Status().Content(Equals("repo → first-change-branch")) + + t.Views().Files().IsEmpty() + + t.Views().Commits(). + Lines( + Contains("Squash merge second-change-branch into first-change-branch").IsSelected(), + Contains("first change"), + Contains("original"), + Contains("three"), + Contains("two"), + Contains("one"), + ) + + t.FileSystem().PathNotPresent(".git/SQUASH_MSG") + }, +}) diff --git a/pkg/integration/tests/branch/squash_merge_uncommitted_with_conflicts.go b/pkg/integration/tests/branch/squash_merge_uncommitted_with_conflicts.go new file mode 100644 index 000000000..5cc413224 --- /dev/null +++ b/pkg/integration/tests/branch/squash_merge_uncommitted_with_conflicts.go @@ -0,0 +1,75 @@ +package branch + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +var SquashMergeUncommittedWithConflicts = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Squash merge a branch without committing, resolve the conflicts, and continue the merge later", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "recency" + }, + SetupRepo: func(shell *Shell) { + shared.MergeConflictsSetup(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("first-change-branch").IsSelected(), + Contains("second-change-branch"), + Contains("original-branch"), + ). + SelectNextItem(). + Press(keys.Branches.MergeIntoCurrentBranch) + + t.ExpectPopup().Menu(). + Title(Equals("Merge")). + Select(Contains("Squash merge and leave uncommitted")). + Confirm() + + t.ExpectPopup().Menu(). + Title(Equals("Conflicts!")). + Select(Contains("View conflicts")). + Confirm() + + t.Views().Files(). + IsFocused(). + Lines( + Contains("UU file").IsSelected(), + ). + Tap(func() { + t.Shell().UpdateFile("file", "resolved content") + }). + Press(keys.Universal.Refresh) + + // The user asked not to commit, so we don't offer to continue the merge + t.Views().Files(). + IsFocused(). + Lines( + Contains("M file"), + ) + + t.Views().Status().Content(Equals("(merging) repo → first-change-branch")) + + t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu) + t.ExpectPopup().Menu(). + Title(Equals("Merge options")). + Select(Contains("continue")). + Confirm() + + t.Views().Status().Content(Equals("repo → first-change-branch")) + + t.Views().Files().IsEmpty() + + t.Views().Commits(). + TopLines( + Contains("Squash merge second-change-branch into first-change-branch"), + Contains("first change"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index b474b99a4..14f97af07 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -110,6 +110,9 @@ var tests = []*components.IntegrationTest{ branch.SortRemoteBranches, branch.SortRemoteBranchesInStackOrder, branch.SquashMerge, + branch.SquashMergeAbortAfterConflicts, + branch.SquashMergeContinueAfterConflicts, + branch.SquashMergeUncommittedWithConflicts, branch.SquashMergeWithStagedChanges, branch.Suggestions, branch.UnsetUpstream, From 09780de32e2fe1a9d6c6c261e8c263eeceeb6037 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:13:23 +0100 Subject: [PATCH 6/7] Use the squash merge message when committing a squash merge After "Squash merge and leave uncommitted", the commit panel opens empty, or with the commit prefix of the branch. Users have to type a message for the squash merge themselves. "git commit" from the terminal suggests the message in SQUASH_MSG instead. Show the message in SQUASH_MSG in the commit panel, unless there is a preserved message. It takes precedence over the commit prefix, like the message of "Squash merge and commit" does. Co-Authored-By: Claude Opus 5.5 --- pkg/commands/git_commands/status.go | 10 ++++ pkg/gui/context/commit_message_context.go | 6 +- pkg/gui/controllers/helpers/commits_helper.go | 14 +++-- .../helpers/working_tree_helper.go | 3 + .../cancel_commit_during_squash_merge.go | 35 +++++++++++ .../commit/commit_during_squash_merge.go | 58 +++++++++++++++++++ pkg/integration/tests/test_list.go | 2 + 7 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 pkg/integration/tests/commit/cancel_commit_during_squash_merge.go create mode 100644 pkg/integration/tests/commit/commit_during_squash_merge.go diff --git a/pkg/commands/git_commands/status.go b/pkg/commands/git_commands/status.go index 8f3b4920d..a01ed201c 100644 --- a/pkg/commands/git_commands/status.go +++ b/pkg/commands/git_commands/status.go @@ -73,6 +73,16 @@ func (self *StatusCommands) IsInSquashMerge() (bool, error) { return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "SQUASH_MSG")) } +// SquashMergeMessage returns the message in SQUASH_MSG (see IsInSquashMerge), or +// an empty string if there is no squash merge in progress +func (self *StatusCommands) SquashMergeMessage() string { + content, err := os.ReadFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "SQUASH_MSG")) + if err != nil { + return "" + } + return strings.TrimSpace(string(content)) +} + func (self *StatusCommands) IsInCherryPick() (bool, error) { hasCherryPickHead, err := self.hasCherryPickHead() if err != nil || hasCherryPickHead { diff --git a/pkg/gui/context/commit_message_context.go b/pkg/gui/context/commit_message_context.go index 7db3a085b..f6db124fe 100644 --- a/pkg/gui/context/commit_message_context.go +++ b/pkg/gui/context/commit_message_context.go @@ -131,6 +131,10 @@ func (self *CommitMessageContext) GetInitialMessage() string { return self.viewModel.initialMessage } +func (self *CommitMessageContext) SetInitialMessage(message string) { + self.viewModel.initialMessage = message +} + func (self *CommitMessageContext) GetHistoryMessage() string { return self.viewModel.historyMessage } @@ -148,7 +152,6 @@ func (self *CommitMessageContext) SetPanelState( summaryTitle string, descriptionTitle string, preserveMessage bool, - initialMessage string, onConfirm func(string, string) error, onSwitchToEditor func(string) error, forceSkipHooks bool, @@ -156,7 +159,6 @@ func (self *CommitMessageContext) SetPanelState( ) { self.viewModel.selectedindex = index self.viewModel.preserveMessage = preserveMessage - self.viewModel.initialMessage = initialMessage self.viewModel.onConfirm = onConfirm self.viewModel.onSwitchToEditor = onSwitchToEditor self.viewModel.forceSkipHooks = forceSkipHooks diff --git a/pkg/gui/controllers/helpers/commits_helper.go b/pkg/gui/controllers/helpers/commits_helper.go index 5e50ba7b2..448c451d3 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -143,10 +143,7 @@ func (self *CommitsHelper) OpenCommitMessagePanel(opts *OpenCommitMessagePanelOp } // When there's no explicit initial message but we're in a preservation - // context, fall back to any previously preserved message. This is stored as - // the "initial" value so the unchanged-message check on close still works - // correctly (in particular, clearing the panel then escaping will notice - // the difference and delete the preserved file). + // context, fall back to any previously preserved message. initialMessage := opts.InitialMessage initialMessageIsPreserved := false if opts.PreserveMessage && initialMessage == "" { @@ -159,7 +156,6 @@ func (self *CommitsHelper) OpenCommitMessagePanel(opts *OpenCommitMessagePanelOp opts.SummaryTitle, opts.DescriptionTitle, opts.PreserveMessage, - initialMessage, onConfirm, opts.OnSwitchToEditor, opts.ForceSkipHooks, @@ -172,6 +168,14 @@ func (self *CommitsHelper) OpenCommitMessagePanel(opts *OpenCommitMessagePanelOp self.SetMessageAndDescriptionInView(initialMessage) } + // PreserveCommitMessage compares the message in the panel with this when the + // panel closes, so that it preserves the message only if the user changed it + // (in particular, clearing the panel then escaping will notice the + // difference and delete the preserved file). Take it from the panel, because + // a message with a blank line after the summary comes out of the panel + // without it. + self.c.Contexts().CommitMessage.SetInitialMessage(self.JoinCommitMessageAndUnwrappedDescription()) + self.c.Context().Push(self.c.Contexts().CommitMessage, types.OnFocusOpts{}) } diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index a11830649..b7ce2f431 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -237,6 +237,9 @@ func (self *WorkingTreeHelper) HandleCommitPress() error { var initialMessage string preservedMessage := self.c.Contexts().CommitMessage.GetPreservedMessageAndLogError() if preservedMessage == "" { + initialMessage = self.c.Git().Status.SquashMergeMessage() + } + if preservedMessage == "" && initialMessage == "" { commitPrefixConfigs := self.commitPrefixConfigsForRepo() for _, commitPrefixConfig := range commitPrefixConfigs { prefixPattern := commitPrefixConfig.Pattern diff --git a/pkg/integration/tests/commit/cancel_commit_during_squash_merge.go b/pkg/integration/tests/commit/cancel_commit_during_squash_merge.go new file mode 100644 index 000000000..a6867acff --- /dev/null +++ b/pkg/integration/tests/commit/cancel_commit_during_squash_merge.go @@ -0,0 +1,35 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CancelCommitDuringSquashMerge = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Open the commit panel during a squash merge started outside lazygit, and close it without changing git's message", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one"). + NewBranch("feature-branch"). + CreateFileAndAdd("feature-file", "content"). + Commit("feature"). + Checkout("master"). + RunCommand([]string{"git", "merge", "--squash", "feature-branch"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Focus(). + Lines( + Equals("A feature-file"), + ). + Press(keys.Files.CommitChanges) + + t.ExpectPopup().CommitMessagePanel(). + InitialText(Equals("Squashed commit of the following:")). + Cancel() + + t.FileSystem().PathNotPresent(".git/LAZYGIT_PENDING_COMMIT") + }, +}) diff --git a/pkg/integration/tests/commit/commit_during_squash_merge.go b/pkg/integration/tests/commit/commit_during_squash_merge.go new file mode 100644 index 000000000..9edf8d4e7 --- /dev/null +++ b/pkg/integration/tests/commit/commit_during_squash_merge.go @@ -0,0 +1,58 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CommitDuringSquashMerge = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Commit the changes of a squash merge that was left uncommitted, using the squash merge message", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Git.CommitPrefix = []config.CommitPrefixConfig{{Pattern: "^(\\w+)-branch$", Replace: "[$1] "}} + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("original-branch"). + EmptyCommit("one"). + NewBranch("feature-branch"). + CreateFileAndAdd("feature-file", "content"). + Commit("feature"). + Checkout("original-branch") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("original-branch").IsSelected(), + Contains("feature-branch"), + ). + SelectNextItem(). + Press(keys.Branches.MergeIntoCurrentBranch) + + t.ExpectPopup().Menu(). + Title(Equals("Merge")). + Select(Contains("Squash merge and leave uncommitted")). + Confirm() + + t.Views().Files(). + Focus(). + Lines( + Equals("A feature-file"), + ). + Press(keys.Files.CommitChanges) + + t.ExpectPopup().CommitMessagePanel(). + Title(Equals("Commit summary")). + InitialText(Equals("Squash merge feature-branch into original-branch")). + Confirm() + + t.Views().Status().Content(Equals("repo → original-branch")) + + t.Views().Commits(). + Lines( + Contains("Squash merge feature-branch into original-branch"), + Contains("one"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 14f97af07..ffde2e1e4 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -137,12 +137,14 @@ var tests = []*components.IntegrationTest{ commit.AmendWhenThereAreConflictsAndCancel, commit.AmendWhenThereAreConflictsAndContinue, commit.AutoWrapMessage, + commit.CancelCommitDuringSquashMerge, commit.Checkout, commit.CheckoutFileFromCommit, commit.CheckoutFileFromRangeSelectionOfCommits, commit.CheckoutFileWithLocalModifications, commit.Commit, commit.CommitAllWithNestedRepository, + commit.CommitDuringSquashMerge, commit.CommitMultiline, commit.CommitSkipHooks, commit.CommitSwitchToEditor, From 8683f6076164e85867b1411a33d8468207562b40 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:14:53 +0100 Subject: [PATCH 7/7] End a squash merge when the user discards all of its changes Discarding a file or directory in the files panel runs git commands on these paths only, and these leave SQUASH_MSG alone. If users discard all changes of a squash merge this way, lazygit keeps showing a merge in progress, and the next commit gets the squash merge message. Abort the squash merge after "Discard all changes" if no changes are left. Untracked files don't count, because a squash merge stages the files that it adds. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/controllers/files_controller.go | 5 +- .../helpers/merge_and_rebase_helper.go | 17 ++++ .../discard_all_changes_of_squash_merge.go | 84 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/file/discard_all_changes_of_squash_merge.go diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index c696f0406..3037943c5 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1891,7 +1891,10 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) + self.c.Refresh(types.RefreshOptions{ + Scope: []types.RefreshableView{types.FILES, types.WORKTREES}, + Then: self.c.Helpers().MergeAndRebase.AbortSquashMergeIfNoChangesAreLeft, + }) return nil }) }, diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 19903a85e..6eb62b917 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -389,6 +389,23 @@ func pathsToDiscardBeforeAbort(status models.WorkingTreeState, files []*models.F }) } +// AbortSquashMergeIfNoChangesAreLeft aborts a squash merge in progress if there +// are no changes left, for example because the user discarded them. Otherwise, +// SQUASH_MSG would keep the squash merge in progress until the next commit, +// and that commit would get its message. Untracked files don't count, because +// a squash merge stages the files that it adds. +func (self *MergeAndRebaseHelper) AbortSquashMergeIfNoChangesAreLeft() error { + if self.c.Git().Status.WorkingTreeState().Effective() != models.WORKING_TREE_STATE_SQUASH_MERGING { + return nil + } + + if lo.SomeBy(self.c.Model().Files, func(file *models.File) bool { return file.Tracked || file.HasStagedChanges }) { + return nil + } + + return self.genericMergeCommand(REBASE_OPTION_ABORT) +} + // PromptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress func (self *MergeAndRebaseHelper) PromptToContinueRebase() { self.continueRebasePromptShowing = true diff --git a/pkg/integration/tests/file/discard_all_changes_of_squash_merge.go b/pkg/integration/tests/file/discard_all_changes_of_squash_merge.go new file mode 100644 index 000000000..4cd39e119 --- /dev/null +++ b/pkg/integration/tests/file/discard_all_changes_of_squash_merge.go @@ -0,0 +1,84 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardAllChangesOfSquashMerge = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discard all changes of a squash merge that was left uncommitted, which ends the squash merge", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.NewBranch("original-branch"). + CreateFileAndAdd("file", "original\n"). + Commit("one"). + NewBranch("feature-branch"). + UpdateFileAndAdd("file", "feature\n"). + CreateFileAndAdd("feature-file", "content"). + Commit("feature"). + Checkout("original-branch"). + CreateFile("untracked-file", "content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("original-branch").IsSelected(), + Contains("feature-branch"), + ). + SelectNextItem(). + Press(keys.Branches.MergeIntoCurrentBranch) + + t.ExpectPopup().Menu(). + Title(Equals("Merge")). + Select(Contains("Squash merge and leave uncommitted")). + Confirm() + + t.Views().Status().Content(Equals("(merging) repo → original-branch")) + + t.Views().Files(). + Focus(). + Lines( + Equals("▼ /"), + Equals(" A feature-file"), + Equals(" M file"), + Equals(" ?? untracked-file"), + ). + NavigateToLine(Equals(" M file")). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Discard changes")). + Select(Contains("Discard all changes")). + Confirm() + }). + Lines( + Equals("▼ /"), + Equals(" A feature-file"), + Equals(" ?? untracked-file"), + ). + NavigateToLine(Equals(" A feature-file")) + + // There are still changes left, so the squash merge is still in progress + t.Views().Status().Content(Equals("(merging) repo → original-branch")) + + t.Views().Files(). + IsFocused(). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Discard changes")). + Select(Contains("Discard all changes")). + Confirm() + }). + Lines( + Equals("?? untracked-file").IsSelected(), + ) + + t.Views().Status().Content(Equals("repo → original-branch")) + + t.FileSystem().PathNotPresent(".git/SQUASH_MSG") + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index ffde2e1e4..ee1ca9a91 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -278,6 +278,7 @@ var tests = []*components.IntegrationTest{ file.CopyMenu, file.DirWithUntrackedFile, file.DirectoryDiffWithRenamedFiles, + file.DiscardAllChangesOfSquashMerge, file.DiscardAllDirChanges, file.DiscardAllDirChangesWhenFiltering, file.DiscardNestedRepositories,