Provide conflict resolution dialogs for non-textual conflicts

This commit is contained in:
Stefan Haller 2025-03-27 15:31:45 +01:00
parent efcd71b296
commit ebb576feac
5 changed files with 168 additions and 1 deletions

View file

@ -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()

View file

@ -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

View file

@ -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",

View file

@ -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"))
},
})

View file

@ -141,6 +141,7 @@ var tests = []*components.IntegrationTest{
conflicts.ResolveExternally,
conflicts.ResolveMultipleFiles,
conflicts.ResolveNoAutoStage,
conflicts.ResolveNonTextualConflicts,
conflicts.ResolveWithoutTrailingLf,
conflicts.UndoChooseHunk,
custom_commands.AccessCommitProperties,