From c7002e5e52e16febcbbac31f844d3bd5e8ddfcd6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:05:44 +0100 Subject: [PATCH] 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,