From ebb576feac4081a8050c80e98a13e8f63abb201b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 27 Mar 2025 15:31:45 +0100 Subject: [PATCH] Provide conflict resolution dialogs for non-textual conflicts --- pkg/commands/git_commands/working_tree.go | 7 ++ pkg/gui/controllers/files_controller.go | 48 +++++++- pkg/i18n/english.go | 8 ++ .../resolve_non_textual_conflicts.go | 105 ++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 5 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/conflicts/resolve_non_textual_conflicts.go diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index e1339ee56..c967ca576 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -341,6 +341,13 @@ func (self *WorkingTreeCommands) RemoveTrackedFiles(name string) error { return self.cmd.New(cmdArgs).Run() } +func (self *WorkingTreeCommands) RemoveConflictedFile(name string) error { + cmdArgs := NewGitCmd("rm").Arg("--", name). + ToArgv() + + return self.cmd.New(cmdArgs).Run() +} + // RemoveUntrackedFiles runs `git clean -fd` func (self *WorkingTreeCommands) RemoveUntrackedFiles() error { cmdArgs := NewGitCmd("clean").Arg("-fd").ToArgv() diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index cf3d0475e..167e29f1b 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -571,13 +571,59 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { return self.switchToMerge() } if file.HasMergeConflicts { - return errors.New(self.c.Tr.FileStagingRequirements) + return self.handleNonInlineConflict(file) } self.c.Context().Push(self.c.Contexts().Staging, opts) return nil } +func (self *FilesController) handleNonInlineConflict(file *models.File) error { + handle := func(command func(command string) error, logText string) error { + self.c.LogAction(logText) + if err := command(file.GetPath()); err != nil { + return err + } + return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) + } + keepItem := &types.MenuItem{ + Label: self.c.Tr.MergeConflictKeepFile, + OnPress: func() error { + return handle(self.c.Git().WorkingTree.StageFile, self.c.Tr.Actions.ResolveConflictByKeepingFile) + }, + Key: 'k', + } + deleteItem := &types.MenuItem{ + Label: self.c.Tr.MergeConflictDeleteFile, + OnPress: func() error { + return handle(self.c.Git().WorkingTree.RemoveConflictedFile, self.c.Tr.Actions.ResolveConflictByDeletingFile) + }, + Key: 'd', + } + items := []*types.MenuItem{} + switch file.ShortStatus { + case "DD": + // For "both deleted" conflicts, deleting the file is the only reasonable thing you can do. + // Restoring to the state before deletion is not the responsibility of a conflict resolution tool. + items = append(items, deleteItem) + case "DU", "UD": + // For these, we put the delete option first because it's the most common one, + // even if it's more destructive. + items = append(items, deleteItem, keepItem) + case "AU", "UA": + // For these, we put the keep option first because it's less destructive, + // and the chances between keep and delete are 50/50. + items = append(items, keepItem, deleteItem) + default: + panic("should only be called if there's a merge conflict") + } + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.MergeConflictsTitle, + Prompt: file.GetMergeStateDescription(self.c.Tr), + Items: items, + }) +} + func (self *FilesController) toggleStagedAll() error { if err := self.toggleStagedAllWithLock(); err != nil { return err diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index eeaf4f2e9..473ce50e8 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -107,6 +107,8 @@ type TranslationSet struct { MergeConflictIncomingDiff string MergeConflictCurrentDiff string MergeConflictPressEnterToResolve string + MergeConflictKeepFile string + MergeConflictDeleteFile string Checkout string CheckoutTooltip string CantCheckoutBranchWhilePulling string @@ -951,6 +953,8 @@ type Actions struct { UnstageFile string UnstageAllFiles string StageAllFiles string + ResolveConflictByKeepingFile string + ResolveConflictByDeletingFile string NotEnoughContextToStage string NotEnoughContextToDiscard string IgnoreExcludeFile string @@ -1128,6 +1132,8 @@ func EnglishTranslationSet() *TranslationSet { MergeConflictIncomingDiff: "Incoming changes:", MergeConflictCurrentDiff: "Current changes:", MergeConflictPressEnterToResolve: "Press %s to resolve.", + MergeConflictKeepFile: "Keep file", + MergeConflictDeleteFile: "Delete file", Checkout: "Checkout", CheckoutTooltip: "Checkout selected item.", CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch", @@ -1968,6 +1974,8 @@ func EnglishTranslationSet() *TranslationSet { UnstageFile: "Unstage file", UnstageAllFiles: "Unstage all files", StageAllFiles: "Stage all files", + ResolveConflictByKeepingFile: "Resolve by keeping file", + ResolveConflictByDeletingFile: "Resolve by deleting file", NotEnoughContextToStage: "Staging or unstaging changes is not possible with a diff context size of 0. Increase the context using '%s'.", NotEnoughContextToDiscard: "Discarding changes is not possible with a diff context size of 0. Increase the context using '%s'.", IgnoreExcludeFile: "Ignore or exclude file", diff --git a/pkg/integration/tests/conflicts/resolve_non_textual_conflicts.go b/pkg/integration/tests/conflicts/resolve_non_textual_conflicts.go new file mode 100644 index 000000000..8601d0680 --- /dev/null +++ b/pkg/integration/tests/conflicts/resolve_non_textual_conflicts.go @@ -0,0 +1,105 @@ +package conflicts + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ResolveNonTextualConflicts = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Resolve non-textual merge conflicts (e.g. one side modified, the other side deleted)", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.RunShellCommand(`echo test1 > both-deleted1.txt`) + shell.RunShellCommand(`echo test2 > both-deleted2.txt`) + shell.RunShellCommand(`git checkout -b conflict && git add both-deleted1.txt both-deleted2.txt`) + shell.RunShellCommand(`echo haha1 > deleted-them1.txt && git add deleted-them1.txt`) + shell.RunShellCommand(`echo haha2 > deleted-them2.txt && git add deleted-them2.txt`) + shell.RunShellCommand(`echo haha1 > deleted-us1.txt && git add deleted-us1.txt`) + shell.RunShellCommand(`echo haha2 > deleted-us2.txt && git add deleted-us2.txt`) + shell.RunShellCommand(`git commit -m one`) + + // stuff on other branch + shell.RunShellCommand(`git branch conflict_second`) + shell.RunShellCommand(`git mv both-deleted1.txt added-them-changed-us1.txt`) + shell.RunShellCommand(`git mv both-deleted2.txt added-them-changed-us2.txt`) + shell.RunShellCommand(`git rm deleted-them1.txt deleted-them2.txt`) + shell.RunShellCommand(`echo modded1 > deleted-us1.txt && git add deleted-us1.txt`) + shell.RunShellCommand(`echo modded2 > deleted-us2.txt && git add deleted-us2.txt`) + shell.RunShellCommand(`git commit -m "two"`) + + // stuff on our branch + shell.RunShellCommand(`git checkout conflict_second`) + shell.RunShellCommand(`git mv both-deleted1.txt changed-them-added-us1.txt`) + shell.RunShellCommand(`git mv both-deleted2.txt changed-them-added-us2.txt`) + shell.RunShellCommand(`echo modded1 > deleted-them1.txt && git add deleted-them1.txt`) + shell.RunShellCommand(`echo modded2 > deleted-them2.txt && git add deleted-them2.txt`) + shell.RunShellCommand(`git rm deleted-us1.txt deleted-us2.txt`) + shell.RunShellCommand(`git commit -m "three"`) + shell.RunShellCommand(`git reset --hard conflict_second`) + shell.RunCommandExpectError([]string{"git", "merge", "conflict"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + resolve := func(filename string, menuChoice string) { + t.Views().Files(). + NavigateToLine(Contains(filename)). + Tap(func() { + t.Views().Main().Content(Contains("Conflict:")) + }). + Press(keys.Universal.GoInto). + Tap(func() { + t.ExpectPopup().Menu().Title(Equals("Merge conflicts")). + Select(Contains(menuChoice)). + Confirm() + }) + } + + t.Views().Files(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Equals(" UA added-them-changed-us1.txt"), + Equals(" UA added-them-changed-us2.txt"), + Equals(" DD both-deleted1.txt"), + Equals(" DD both-deleted2.txt"), + Equals(" AU changed-them-added-us1.txt"), + Equals(" AU changed-them-added-us2.txt"), + Equals(" UD deleted-them1.txt"), + Equals(" UD deleted-them2.txt"), + Equals(" DU deleted-us1.txt"), + Equals(" DU deleted-us2.txt"), + ). + Tap(func() { + resolve("added-them-changed-us1.txt", "Delete file") + resolve("added-them-changed-us2.txt", "Keep file") + resolve("both-deleted1.txt", "Delete file") + resolve("both-deleted2.txt", "Delete file") + resolve("changed-them-added-us1.txt", "Delete file") + resolve("changed-them-added-us2.txt", "Keep file") + resolve("deleted-them1.txt", "Delete file") + resolve("deleted-them2.txt", "Keep file") + resolve("deleted-us1.txt", "Delete file") + resolve("deleted-us2.txt", "Keep file") + }). + Lines( + Equals("▼ /"), + Equals(" A added-them-changed-us2.txt"), + Equals(" D changed-them-added-us1.txt"), + Equals(" D deleted-them1.txt"), + Equals(" A deleted-us2.txt"), + ) + + t.FileSystem(). + PathNotPresent("added-them-changed-us1.txt"). + FileContent("added-them-changed-us2.txt", Equals("test2\n")). + PathNotPresent("both-deleted1.txt"). + PathNotPresent("both-deleted2.txt"). + PathNotPresent("changed-them-added-us1.txt"). + FileContent("changed-them-added-us2.txt", Equals("test2\n")). + PathNotPresent("deleted-them1.txt"). + FileContent("deleted-them2.txt", Equals("modded2\n")). + PathNotPresent("deleted-us1.txt"). + FileContent("deleted-us2.txt", Equals("modded2\n")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 0d6884071..2590e03ca 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -141,6 +141,7 @@ var tests = []*components.IntegrationTest{ conflicts.ResolveExternally, conflicts.ResolveMultipleFiles, conflicts.ResolveNoAutoStage, + conflicts.ResolveNonTextualConflicts, conflicts.ResolveWithoutTrailingLf, conflicts.UndoChooseHunk, custom_commands.AccessCommitProperties,