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,