Skip to content

Commit 9469037

Browse files
authored
Resolve non-inline merge conflicts (#4431)
- **PR Description** We offer an inline merge conflict editor for text conflicts (i.e. where both sides modify the same section of code). However, there are other types of conflicts that can't be resolved this way, for example when one side modifies a file and the other side deletes it. For these cases we would previously only show `* Unmerged path` in the main view, which isn't helpful. (Also, for some of these we would split the main view and show this text both in the unstaged changes and staged changes views, which is a bit embarrassing.) But more importantly, it was very unclear how to resolve such a conflict. The only option we had was to discard the file, which would basically pick "ours" and discard "theirs"; but there was no way to do the opposite. This PR improves the situation in two ways: - it shows elaborate help texts in the main view explaining the situation (which, in some obscure cases, can be extremely non-obvious, and a `git status` on the command line doesn't help at all either). For `UD` and `DU` conflicts we also show the diff of the side that didn't delete the file; this is usually essential for resolving the conflict properly, because it's likely that this diff needs to be applied manually somewhere else. - when pressing enter, we show a dialog with options to keep the modified file or to delete it. ![image](https://github.com/user-attachments/assets/568cdaa9-0945-4111-aa99-4bbbf465bf8b) A note about terminology: a common way to describe the two sides of a merge is "ours" and "theirs". I dislike these terms, because while they work well for merges, they are backwards [for rebases](https://git-scm.com/docs/git-checkout#Documentation/git-checkout.txt---ours). I chose to avoid them in this PR, and to use the terms "current" and "incoming" instead (like in the conflict code lenses in VS Code), which I think work much better in general; however, they might not be easy to understand when they occur in the middle of a sentence, so maybe we should put them in quotes there. - **Please check if the PR fulfills these requirements** * [x] Cheatsheets are up-to-date (run `go generate ./...`) * [x] Code has been formatted (see [here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting)) * [x] Tests have been added/updated (see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md) for the integration test guide) * [x] Text is internationalised (see [here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation)) * [ ] If a new UserConfig entry was added, make sure it can be hot-reloaded (see [here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig)) * [ ] Docs have been updated if necessary * [x] You've read through your own file changes for silly mistakes etc
2 parents a09ca59 + ebb576f commit 9469037

File tree

7 files changed

+239
-4
lines changed

7 files changed

+239
-4
lines changed

pkg/commands/git_commands/working_tree.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,13 @@ func (self *WorkingTreeCommands) RemoveTrackedFiles(name string) error {
341341
return self.cmd.New(cmdArgs).Run()
342342
}
343343

344+
func (self *WorkingTreeCommands) RemoveConflictedFile(name string) error {
345+
cmdArgs := NewGitCmd("rm").Arg("--", name).
346+
ToArgv()
347+
348+
return self.cmd.New(cmdArgs).Run()
349+
}
350+
344351
// RemoveUntrackedFiles runs `git clean -fd`
345352
func (self *WorkingTreeCommands) RemoveUntrackedFiles() error {
346353
cmdArgs := NewGitCmd("clean").Arg("-fd").ToArgv()

pkg/commands/models/file.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package models
22

33
import (
4+
"github.com/jesseduffield/lazygit/pkg/i18n"
45
"github.com/jesseduffield/lazygit/pkg/utils"
56
"github.com/samber/lo"
67
)
@@ -101,6 +102,22 @@ func (f *File) GetIsFile() bool {
101102
return true
102103
}
103104

105+
func (f *File) GetMergeStateDescription(tr *i18n.TranslationSet) string {
106+
m := map[string]string{
107+
"DD": tr.MergeConflictDescription_DD,
108+
"AU": tr.MergeConflictDescription_AU,
109+
"UA": tr.MergeConflictDescription_UA,
110+
"DU": tr.MergeConflictDescription_DU,
111+
"UD": tr.MergeConflictDescription_UD,
112+
}
113+
114+
if description, ok := m[f.ShortStatus]; ok {
115+
return description
116+
}
117+
118+
panic("should only be called if there's a merge conflict")
119+
}
120+
104121
type StatusFields struct {
105122
HasStagedChanges bool
106123
HasUnstagedChanges bool

pkg/gui/controllers/files_controller.go

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,38 @@ func (self *FilesController) GetOnRenderToMain() func() {
268268
self.c.Helpers().MergeConflicts.Render()
269269
return
270270
}
271+
} else if node.File != nil && node.File.HasMergeConflicts {
272+
opts := types.RefreshMainOpts{
273+
Pair: self.c.MainViewPairs().Normal,
274+
Main: &types.ViewUpdateOpts{
275+
Title: self.c.Tr.DiffTitle,
276+
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
277+
},
278+
}
279+
message := node.File.GetMergeStateDescription(self.c.Tr)
280+
message += "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve,
281+
self.c.UserConfig().Keybinding.Universal.GoInto)
282+
if self.c.Views().Main.InnerWidth() > 70 {
283+
// If the main view is very wide, wrap the message to increase readability
284+
lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4)
285+
message = strings.Join(lines, "\n")
286+
}
287+
if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" {
288+
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()})
289+
task := types.NewRunPtyTask(cmdObj.GetCmd())
290+
task.Prefix = message + "\n\n"
291+
if node.File.ShortStatus == "DU" {
292+
task.Prefix += self.c.Tr.MergeConflictIncomingDiff
293+
} else {
294+
task.Prefix += self.c.Tr.MergeConflictCurrentDiff
295+
}
296+
task.Prefix += "\n\n"
297+
opts.Main.Task = task
298+
} else {
299+
opts.Main.Task = types.NewRenderStringTask(message)
300+
}
301+
self.c.RenderToMainViews(opts)
302+
return
271303
}
272304

273305
self.c.Helpers().MergeConflicts.ResetMergeState()
@@ -539,13 +571,59 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
539571
return self.switchToMerge()
540572
}
541573
if file.HasMergeConflicts {
542-
return errors.New(self.c.Tr.FileStagingRequirements)
574+
return self.handleNonInlineConflict(file)
543575
}
544576

545577
self.c.Context().Push(self.c.Contexts().Staging, opts)
546578
return nil
547579
}
548580

581+
func (self *FilesController) handleNonInlineConflict(file *models.File) error {
582+
handle := func(command func(command string) error, logText string) error {
583+
self.c.LogAction(logText)
584+
if err := command(file.GetPath()); err != nil {
585+
return err
586+
}
587+
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
588+
}
589+
keepItem := &types.MenuItem{
590+
Label: self.c.Tr.MergeConflictKeepFile,
591+
OnPress: func() error {
592+
return handle(self.c.Git().WorkingTree.StageFile, self.c.Tr.Actions.ResolveConflictByKeepingFile)
593+
},
594+
Key: 'k',
595+
}
596+
deleteItem := &types.MenuItem{
597+
Label: self.c.Tr.MergeConflictDeleteFile,
598+
OnPress: func() error {
599+
return handle(self.c.Git().WorkingTree.RemoveConflictedFile, self.c.Tr.Actions.ResolveConflictByDeletingFile)
600+
},
601+
Key: 'd',
602+
}
603+
items := []*types.MenuItem{}
604+
switch file.ShortStatus {
605+
case "DD":
606+
// For "both deleted" conflicts, deleting the file is the only reasonable thing you can do.
607+
// Restoring to the state before deletion is not the responsibility of a conflict resolution tool.
608+
items = append(items, deleteItem)
609+
case "DU", "UD":
610+
// For these, we put the delete option first because it's the most common one,
611+
// even if it's more destructive.
612+
items = append(items, deleteItem, keepItem)
613+
case "AU", "UA":
614+
// For these, we put the keep option first because it's less destructive,
615+
// and the chances between keep and delete are 50/50.
616+
items = append(items, keepItem, deleteItem)
617+
default:
618+
panic("should only be called if there's a merge conflict")
619+
}
620+
return self.c.Menu(types.CreateMenuOptions{
621+
Title: self.c.Tr.MergeConflictsTitle,
622+
Prompt: file.GetMergeStateDescription(self.c.Tr),
623+
Items: items,
624+
})
625+
}
626+
549627
func (self *FilesController) toggleStagedAll() error {
550628
if err := self.toggleStagedAllWithLock(); err != nil {
551629
return err

pkg/i18n/english.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ type TranslationSet struct {
9999
FilterLabelUntrackedFiles string
100100
FilterLabelConflictingFiles string
101101
MergeConflictsTitle string
102+
MergeConflictDescription_DD string
103+
MergeConflictDescription_AU string
104+
MergeConflictDescription_UA string
105+
MergeConflictDescription_DU string
106+
MergeConflictDescription_UD string
107+
MergeConflictIncomingDiff string
108+
MergeConflictCurrentDiff string
109+
MergeConflictPressEnterToResolve string
110+
MergeConflictKeepFile string
111+
MergeConflictDeleteFile string
102112
Checkout string
103113
CheckoutTooltip string
104114
CantCheckoutBranchWhilePulling string
@@ -943,6 +953,8 @@ type Actions struct {
943953
UnstageFile string
944954
UnstageAllFiles string
945955
StageAllFiles string
956+
ResolveConflictByKeepingFile string
957+
ResolveConflictByDeletingFile string
946958
NotEnoughContextToStage string
947959
NotEnoughContextToDiscard string
948960
IgnoreExcludeFile string
@@ -1112,6 +1124,16 @@ func EnglishTranslationSet() *TranslationSet {
11121124
PullTooltip: "Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch.",
11131125
Scroll: "Scroll",
11141126
MergeConflictsTitle: "Merge conflicts",
1127+
MergeConflictDescription_DD: "Conflict: this file was moved or renamed both in the current and the incoming changes, but to different destinations. I don't know which ones, but they should both show up as conflicts too (marked 'AU' and 'UA', respectively). The most likely resolution is to delete this file, and pick one of the destinations and delete the other.",
1128+
MergeConflictDescription_AU: "Conflict: this file is the destination of a move or rename in the current changes, but was moved or renamed to a different destination in the incoming changes. That other destination should also show up as a conflict (marked 'UA'), as well as the file that both were renamed from (marked 'DD').",
1129+
MergeConflictDescription_UA: "Conflict: this file is the destination of a move or rename in the incoming changes, but was moved or renamed to a different destination in the current changes. That other destination should also show up as a conflict (marked 'AU'), as well as the file that both were renamed from (marked 'DD').",
1130+
MergeConflictDescription_DU: "Conflict: this file was deleted in the current changes and modified in the incoming changes.\n\nThe most likely resolution is to delete the file after applying the incoming modifications manually to some other place in the code.",
1131+
MergeConflictDescription_UD: "Conflict: this file was modified in the current changes and deleted in incoming changes.\n\nThe most likely resolution is to delete the file after applying the current modifications manually to some other place in the code.",
1132+
MergeConflictIncomingDiff: "Incoming changes:",
1133+
MergeConflictCurrentDiff: "Current changes:",
1134+
MergeConflictPressEnterToResolve: "Press %s to resolve.",
1135+
MergeConflictKeepFile: "Keep file",
1136+
MergeConflictDeleteFile: "Delete file",
11151137
Checkout: "Checkout",
11161138
CheckoutTooltip: "Checkout selected item.",
11171139
CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch",
@@ -1952,6 +1974,8 @@ func EnglishTranslationSet() *TranslationSet {
19521974
UnstageFile: "Unstage file",
19531975
UnstageAllFiles: "Unstage all files",
19541976
StageAllFiles: "Stage all files",
1977+
ResolveConflictByKeepingFile: "Resolve by keeping file",
1978+
ResolveConflictByDeletingFile: "Resolve by deleting file",
19551979
NotEnoughContextToStage: "Staging or unstaging changes is not possible with a diff context size of 0. Increase the context using '%s'.",
19561980
NotEnoughContextToDiscard: "Discarding changes is not possible with a diff context size of 0. Increase the context using '%s'.",
19571981
IgnoreExcludeFile: "Ignore or exclude file",

pkg/integration/components/file_system.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,25 @@ type FileSystem struct {
1010
}
1111

1212
// This does _not_ check the files panel, it actually checks the filesystem
13-
func (self *FileSystem) PathPresent(path string) {
13+
func (self *FileSystem) PathPresent(path string) *FileSystem {
1414
self.assertWithRetries(func() (bool, string) {
1515
_, err := os.Stat(path)
1616
return err == nil, fmt.Sprintf("Expected path '%s' to exist, but it does not", path)
1717
})
18+
return self
1819
}
1920

2021
// This does _not_ check the files panel, it actually checks the filesystem
21-
func (self *FileSystem) PathNotPresent(path string) {
22+
func (self *FileSystem) PathNotPresent(path string) *FileSystem {
2223
self.assertWithRetries(func() (bool, string) {
2324
_, err := os.Stat(path)
2425
return os.IsNotExist(err), fmt.Sprintf("Expected path '%s' to not exist, but it does", path)
2526
})
27+
return self
2628
}
2729

2830
// Asserts that the file at the given path has the given content
29-
func (self *FileSystem) FileContent(path string, matcher *TextMatcher) {
31+
func (self *FileSystem) FileContent(path string, matcher *TextMatcher) *FileSystem {
3032
self.assertWithRetries(func() (bool, string) {
3133
_, err := os.Stat(path)
3234
if os.IsNotExist(err) {
@@ -46,4 +48,5 @@ func (self *FileSystem) FileContent(path string, matcher *TextMatcher) {
4648

4749
return true, ""
4850
})
51+
return self
4952
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package conflicts
2+
3+
import (
4+
"github.com/jesseduffield/lazygit/pkg/config"
5+
. "github.com/jesseduffield/lazygit/pkg/integration/components"
6+
)
7+
8+
var ResolveNonTextualConflicts = NewIntegrationTest(NewIntegrationTestArgs{
9+
Description: "Resolve non-textual merge conflicts (e.g. one side modified, the other side deleted)",
10+
ExtraCmdArgs: []string{},
11+
Skip: false,
12+
SetupConfig: func(config *config.AppConfig) {},
13+
SetupRepo: func(shell *Shell) {
14+
shell.RunShellCommand(`echo test1 > both-deleted1.txt`)
15+
shell.RunShellCommand(`echo test2 > both-deleted2.txt`)
16+
shell.RunShellCommand(`git checkout -b conflict && git add both-deleted1.txt both-deleted2.txt`)
17+
shell.RunShellCommand(`echo haha1 > deleted-them1.txt && git add deleted-them1.txt`)
18+
shell.RunShellCommand(`echo haha2 > deleted-them2.txt && git add deleted-them2.txt`)
19+
shell.RunShellCommand(`echo haha1 > deleted-us1.txt && git add deleted-us1.txt`)
20+
shell.RunShellCommand(`echo haha2 > deleted-us2.txt && git add deleted-us2.txt`)
21+
shell.RunShellCommand(`git commit -m one`)
22+
23+
// stuff on other branch
24+
shell.RunShellCommand(`git branch conflict_second`)
25+
shell.RunShellCommand(`git mv both-deleted1.txt added-them-changed-us1.txt`)
26+
shell.RunShellCommand(`git mv both-deleted2.txt added-them-changed-us2.txt`)
27+
shell.RunShellCommand(`git rm deleted-them1.txt deleted-them2.txt`)
28+
shell.RunShellCommand(`echo modded1 > deleted-us1.txt && git add deleted-us1.txt`)
29+
shell.RunShellCommand(`echo modded2 > deleted-us2.txt && git add deleted-us2.txt`)
30+
shell.RunShellCommand(`git commit -m "two"`)
31+
32+
// stuff on our branch
33+
shell.RunShellCommand(`git checkout conflict_second`)
34+
shell.RunShellCommand(`git mv both-deleted1.txt changed-them-added-us1.txt`)
35+
shell.RunShellCommand(`git mv both-deleted2.txt changed-them-added-us2.txt`)
36+
shell.RunShellCommand(`echo modded1 > deleted-them1.txt && git add deleted-them1.txt`)
37+
shell.RunShellCommand(`echo modded2 > deleted-them2.txt && git add deleted-them2.txt`)
38+
shell.RunShellCommand(`git rm deleted-us1.txt deleted-us2.txt`)
39+
shell.RunShellCommand(`git commit -m "three"`)
40+
shell.RunShellCommand(`git reset --hard conflict_second`)
41+
shell.RunCommandExpectError([]string{"git", "merge", "conflict"})
42+
},
43+
Run: func(t *TestDriver, keys config.KeybindingConfig) {
44+
resolve := func(filename string, menuChoice string) {
45+
t.Views().Files().
46+
NavigateToLine(Contains(filename)).
47+
Tap(func() {
48+
t.Views().Main().Content(Contains("Conflict:"))
49+
}).
50+
Press(keys.Universal.GoInto).
51+
Tap(func() {
52+
t.ExpectPopup().Menu().Title(Equals("Merge conflicts")).
53+
Select(Contains(menuChoice)).
54+
Confirm()
55+
})
56+
}
57+
58+
t.Views().Files().
59+
IsFocused().
60+
Lines(
61+
Equals("▼ /").IsSelected(),
62+
Equals(" UA added-them-changed-us1.txt"),
63+
Equals(" UA added-them-changed-us2.txt"),
64+
Equals(" DD both-deleted1.txt"),
65+
Equals(" DD both-deleted2.txt"),
66+
Equals(" AU changed-them-added-us1.txt"),
67+
Equals(" AU changed-them-added-us2.txt"),
68+
Equals(" UD deleted-them1.txt"),
69+
Equals(" UD deleted-them2.txt"),
70+
Equals(" DU deleted-us1.txt"),
71+
Equals(" DU deleted-us2.txt"),
72+
).
73+
Tap(func() {
74+
resolve("added-them-changed-us1.txt", "Delete file")
75+
resolve("added-them-changed-us2.txt", "Keep file")
76+
resolve("both-deleted1.txt", "Delete file")
77+
resolve("both-deleted2.txt", "Delete file")
78+
resolve("changed-them-added-us1.txt", "Delete file")
79+
resolve("changed-them-added-us2.txt", "Keep file")
80+
resolve("deleted-them1.txt", "Delete file")
81+
resolve("deleted-them2.txt", "Keep file")
82+
resolve("deleted-us1.txt", "Delete file")
83+
resolve("deleted-us2.txt", "Keep file")
84+
}).
85+
Lines(
86+
Equals("▼ /"),
87+
Equals(" A added-them-changed-us2.txt"),
88+
Equals(" D changed-them-added-us1.txt"),
89+
Equals(" D deleted-them1.txt"),
90+
Equals(" A deleted-us2.txt"),
91+
)
92+
93+
t.FileSystem().
94+
PathNotPresent("added-them-changed-us1.txt").
95+
FileContent("added-them-changed-us2.txt", Equals("test2\n")).
96+
PathNotPresent("both-deleted1.txt").
97+
PathNotPresent("both-deleted2.txt").
98+
PathNotPresent("changed-them-added-us1.txt").
99+
FileContent("changed-them-added-us2.txt", Equals("test2\n")).
100+
PathNotPresent("deleted-them1.txt").
101+
FileContent("deleted-them2.txt", Equals("modded2\n")).
102+
PathNotPresent("deleted-us1.txt").
103+
FileContent("deleted-us2.txt", Equals("modded2\n"))
104+
},
105+
})

pkg/integration/tests/test_list.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ var tests = []*components.IntegrationTest{
141141
conflicts.ResolveExternally,
142142
conflicts.ResolveMultipleFiles,
143143
conflicts.ResolveNoAutoStage,
144+
conflicts.ResolveNonTextualConflicts,
144145
conflicts.ResolveWithoutTrailingLf,
145146
conflicts.UndoChooseHunk,
146147
custom_commands.AccessCommitProperties,

0 commit comments

Comments
 (0)