From de6b6ef906d249b064101b4ec4d83ae937c258ff Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 15:47:09 +0200 Subject: [PATCH 1/6] Addition to AGENTS.md --- AGENTS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c379b303032..50b808fb40e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -252,6 +252,30 @@ keep the call site fluent. Prefer `assert.Equal` (and friends) over hand-rolled `if` checks. The failure messages are more useful and the intent is clearer at a glance. +## Translatable strings use Go templates, not `%s` + +Never put `fmt.Sprintf`-style placeholders (`%s`, `%d`, …) in translatable +strings — the fields of `TranslationSet` and `Actions` in +`pkg/i18n/english.go`. Use named Go-template placeholders and fill them in with +`utils.ResolvePlaceholderString`: + +```go +// in english.go +DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?", + +// at the call site +utils.ResolvePlaceholderString( + self.c.Tr.DeleteBranchTitle, + map[string]string{"selectedBranchName": branchName}, +) +``` + +Named placeholders tell localizers what each value is (a bare `%s` says +nothing, and translators can't safely reorder positional verbs across +languages), and the map form extends cleanly when a string later needs more +than one placeholder. This holds for every user-facing string, including short +ones like disabled-action reasons and toasts. + ## Code comments are for future readers, not development history Comments in source code explain *why this code is shaped the way it is*. They From 41d92aac3622aca5b4903c83b231daa1e31dcaa3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 13:31:42 +0200 Subject: [PATCH 2/6] Extract a predicate for conflicts that need a resolution dialog Some merge conflicts can't be resolved by editing markers in the merge view; they require a dialog that picks one side (the "non-textual" conflicts like DD/AU/UA/UD/DU). Both `enter` and, soon, `space` need to recognize these, so pull the test into a shared predicate and rename handleNonInlineConflict to openConflictResolutionMenu to match. Restructure EnterFile so the predicate is checked first, ahead of the submodule and inline-conflict branches. This is its final shape: upcoming commits only add the submodule case to the predicate, with no further reordering. Behavior is unchanged here, since the predicate is currently false for submodules. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/files_controller.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index d048da50897..9ac14f3e582 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -683,6 +683,10 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { file := node.File + if self.conflictNeedsResolutionDialog(file) { + return self.openConflictResolutionMenu(file) + } + submoduleConfigs := self.c.Model().Submodules if file.IsSubmodule(submoduleConfigs) { submoduleConfig := file.SubmoduleConfig(submoduleConfigs) @@ -692,9 +696,6 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { if file.HasInlineMergeConflicts { return self.switchToMerge() } - if file.HasMergeConflicts { - return self.handleNonInlineConflict(file) - } context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging) self.c.Context().Push(context, opts) @@ -703,7 +704,19 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { return nil } -func (self *FilesController) handleNonInlineConflict(file *models.File) error { +// conflictNeedsResolutionDialog reports whether a file's merge conflict can only +// be resolved through a dialog that picks one side, as opposed to editing +// conflict markers in the merge view. These are the "non-textual" conflicts, +// e.g. one side modified a file while the other deleted it (DD/AU/UA/UD/DU). +func (self *FilesController) conflictNeedsResolutionDialog(file *models.File) bool { + if file == nil || !file.HasMergeConflicts { + return false + } + + return !file.HasInlineMergeConflicts +} + +func (self *FilesController) openConflictResolutionMenu(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 { From 860f89e0c9fd7940262c56f7294f4f0d4865c142 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 13:38:44 +0200 Subject: [PATCH 3/6] Route space to the conflict picker for non-textual conflicts For a non-textual conflict (e.g. DD/AU/UA/UD/DU), pressing space used to run the normal stage path, which did something unclear: `git add` happens to resolve the conflict by keeping the file, but that's neither obvious nor symmetric. Route a single such file to the same Keep/Delete picker that enter opens, so space and enter agree. For a range selection that includes one of these conflicts, staging makes no sense, so disable it with a toast that points the user at resolving them one at a time. (Entering a range was already disabled with the standard toast.) Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/files_controller.go | 29 +++++++++- pkg/i18n/english.go | 2 + .../space_on_non_textual_conflict.go | 56 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/conflicts/space_on_non_textual_conflict.go diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 9ac14f3e582..c9034ec9cfd 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -44,7 +44,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types { Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withItems(self.press), - GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected())), + GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canStageSelection))), Description: self.c.Tr.Stage, Tooltip: self.c.Tr.StageTooltip, DisplayOnScreen: true, @@ -583,6 +583,12 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e } func (self *FilesController) press(nodes []*filetree.FileNode) error { + // A single file with a conflict that can only be resolved through a dialog + // can't be staged; route it to the same picker that `enter` uses instead. + if len(nodes) == 1 && self.conflictNeedsResolutionDialog(nodes[0].File) { + return self.openConflictResolutionMenu(nodes[0].File) + } + if err := self.pressWithLock(nodes); err != nil { return err } @@ -716,6 +722,27 @@ func (self *FilesController) conflictNeedsResolutionDialog(file *models.File) bo return !file.HasInlineMergeConflicts } +// canStageSelection disables staging when a multiple selection includes a file +// with a conflict that must be resolved through a dialog; those have to be +// resolved one at a time. +func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *types.DisabledReason { + if len(nodes) > 1 { + for _, node := range nodes { + if node.SomeFile(self.conflictNeedsResolutionDialog) { + return &types.DisabledReason{ + Text: utils.ResolvePlaceholderString( + self.c.Tr.StageConflictsRangeDisabled, map[string]string{ + "goIntoKey": self.c.UserConfig().Keybinding.Universal.GoInto.String(), + }, + ), + } + } + } + } + + return nil +} + func (self *FilesController) openConflictResolutionMenu(file *models.File) error { handle := func(command func(command string) error, logText string) error { self.c.LogAction(logText) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 8dd40016af9..3928aac38c1 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -101,6 +101,7 @@ type TranslationSet struct { MergeConflictPressEnterToResolve string MergeConflictKeepFile string MergeConflictDeleteFile string + StageConflictsRangeDisabled string Checkout string CheckoutTooltip string CantCheckoutBranchWhilePulling string @@ -1200,6 +1201,7 @@ func EnglishTranslationSet() *TranslationSet { MergeConflictPressEnterToResolve: "Press %s to resolve.", MergeConflictKeepFile: "Keep file", MergeConflictDeleteFile: "Delete file", + StageConflictsRangeDisabled: "Cannot stage a selection that includes files with merge conflicts; resolve them individually with {{.goIntoKey}} first.", Checkout: "Checkout", CheckoutTooltip: "Checkout selected item.", CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch", diff --git a/pkg/integration/tests/conflicts/space_on_non_textual_conflict.go b/pkg/integration/tests/conflicts/space_on_non_textual_conflict.go new file mode 100644 index 00000000000..2899fadffe4 --- /dev/null +++ b/pkg/integration/tests/conflicts/space_on_non_textual_conflict.go @@ -0,0 +1,56 @@ +package conflicts + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SpaceOnNonTextualConflict = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Pressing space on a non-textual conflict opens the resolution menu; staging is disabled for a range that includes one", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.RunShellCommand(`echo 1 > foo && echo 1 > bar`) + shell.RunShellCommand(`git checkout -b base && git add . && git commit -m base`) + + // theirs: delete foo, modify bar + shell.RunShellCommand(`git checkout -b theirs`) + shell.RunShellCommand(`git rm foo && echo 2 > bar && git add bar && git commit -m theirs`) + + // ours: modify foo, delete bar + shell.RunShellCommand(`git checkout base && git checkout -b ours`) + shell.RunShellCommand(`echo 2 > foo && git add foo && git rm bar && git commit -m ours`) + + shell.RunCommandExpectError([]string{"git", "merge", "theirs"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("DU bar"), + Contains("UD foo"), + ). + // Pressing space on a single non-textual conflict opens the + // resolution menu rather than trying to stage it. + NavigateToLine(Contains("bar")). + PressPrimaryAction(). + Tap(func() { + t.ExpectPopup().Menu().Title(Equals("Merge conflicts")).Cancel() + }). + // Staging is disabled for a range selection that includes a conflict. + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("foo")). + PressPrimaryAction(). + Tap(func() { + t.ExpectToast(Contains("Cannot stage a selection that includes files with merge conflicts")) + }). + // Entering a range selection is disabled too, with the usual toast. + Press(keys.Universal.GoInto). + Tap(func() { + t.ExpectToast(Contains("does not support range selection")) + }) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 3cadf6843e2..b2757736273 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -172,6 +172,7 @@ var tests = []*components.IntegrationTest{ conflicts.ResolveNoAutoStage, conflicts.ResolveNonTextualConflicts, conflicts.ResolveWithoutTrailingLf, + conflicts.SpaceOnNonTextualConflict, conflicts.UndoChooseHunk, custom_commands.AccessCommitProperties, custom_commands.BasicCommand, From afe4d14106adcffe81f5f04dbbe94578dc0881d1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 14:04:07 +0200 Subject: [PATCH 4/6] Resolve submodule conflicts through a picker When both sides of a merge moved a submodule's gitlink, git reports it as "UU". Pressing space used to fall into the submodule no-op guard and pop the confusing "Nothing to stage..." error, and enter just entered the submodule, which does nothing to resolve the superproject conflict. Treat a conflicted submodule like the other non-textual conflicts: both space and enter now open a picker offering the two candidate commits, "current" and "incoming", each labelled with its summary. `git checkout --ours/--theirs` is a no-op on gitlinks, so we resolve by checking the submodule out at the chosen commit and staging it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_commands/submodule.go | 54 +++++++++++++ pkg/commands/git_commands/submodule_test.go | 81 +++++++++++++++++++ pkg/gui/controllers/files_controller.go | 74 ++++++++++++++++- pkg/i18n/english.go | 10 +++ .../tests/submodule/resolve_conflict.go | 73 +++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 6 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 pkg/commands/git_commands/submodule_test.go create mode 100644 pkg/integration/tests/submodule/resolve_conflict.go diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index 7a3cb687bc9..d5852d7795e 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -111,6 +111,60 @@ func (self *SubmoduleCommands) AnyHaveStageableChanges(paths []string) (bool, er }), nil } +// GetConflictCommits returns the three gitlink commits of a conflicted submodule +// from the index: the merge base, our (current) commit, and their (incoming) +// commit. Any of them can be empty if that stage is absent (e.g. a submodule +// that was added on only one side). The path is relative to the repo root. +func (self *SubmoduleCommands) GetConflictCommits(path string) (base string, ours string, theirs string, err error) { + cmdArgs := NewGitCmd("ls-files").Arg("-u", "-z", "--", path).ToArgv() + output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() + if err != nil { + return "", "", "", err + } + + // Each NUL-terminated entry looks like " \t". + for _, entry := range strings.Split(output, "\x00") { + // fields are split on the tab and the spaces, so the leading three are + // always mode, sha, stage regardless of what the path contains. + fields := strings.Fields(entry) + if len(fields) < 3 { + continue + } + switch fields[2] { + case "1": + base = fields[1] + case "2": + ours = fields[1] + case "3": + theirs = fields[1] + } + } + + return base, ours, theirs, nil +} + +// GetCommitSummary returns " " for a commit inside the +// submodule at the given path, for display in the conflict menu. +func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string, error) { + cmdArgs := NewGitCmd("log"). + Dir(path). + Arg("--format=%h %s", "--max-count=1", sha). + Config("log.showsignature=false"). + ToArgv() + + summary, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() + return strings.TrimSpace(summary), err +} + +// CheckoutConflictCommit resolves a submodule conflict by checking the submodule +// out at the given commit. `git checkout --ours/--theirs` is a no-op on +// gitlinks, so we check out the chosen commit in the submodule itself; the +// caller then stages the submodule to record the resolution. +func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error { + cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv() + return self.cmd.New(cmdArgs).Run() +} + func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { // if the path does not exist then it hasn't yet been initialized so we'll swallow the error // because the intention here is to have no dirty worktree state diff --git a/pkg/commands/git_commands/submodule_test.go b/pkg/commands/git_commands/submodule_test.go new file mode 100644 index 00000000000..4683b14cdda --- /dev/null +++ b/pkg/commands/git_commands/submodule_test.go @@ -0,0 +1,81 @@ +package git_commands + +import ( + "testing" + + "github.com/go-errors/errors" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/stretchr/testify/assert" +) + +func TestSubmoduleGetConflictCommits(t *testing.T) { + type scenario struct { + testName string + output string + expectedBase string + expectedOurs string + expectedTheirs string + } + + scenarios := []scenario{ + { + testName: "all three stages present (both modified)", + output: "160000 aaaaaaa 1\tmysub\x00160000 bbbbbbb 2\tmysub\x00160000 ccccccc 3\tmysub\x00", + expectedBase: "aaaaaaa", + expectedOurs: "bbbbbbb", + expectedTheirs: "ccccccc", + }, + { + testName: "only our and their stages (added on both sides)", + output: "160000 bbbbbbb 2\tmysub\x00160000 ccccccc 3\tmysub\x00", + expectedBase: "", + expectedOurs: "bbbbbbb", + expectedTheirs: "ccccccc", + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + runner := oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"ls-files", "-u", "-z", "--", "mysub"}, s.output, nil) + instance := buildSubmoduleCommands(commonDeps{runner: runner}) + + base, ours, theirs, err := instance.GetConflictCommits("mysub") + assert.NoError(t, err) + assert.Equal(t, s.expectedBase, base) + assert.Equal(t, s.expectedOurs, ours) + assert.Equal(t, s.expectedTheirs, theirs) + runner.CheckForMissingCalls() + }) + } +} + +func TestSubmoduleGetConflictCommitsError(t *testing.T) { + runner := oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"ls-files", "-u", "-z", "--", "mysub"}, "", errors.New("error")) + instance := buildSubmoduleCommands(commonDeps{runner: runner}) + + _, _, _, err := instance.GetConflictCommits("mysub") + assert.Error(t, err) + runner.CheckForMissingCalls() +} + +func TestSubmoduleGetCommitSummary(t *testing.T) { + runner := oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"-c", "log.showsignature=false", "-C", "mysub", "log", "--format=%h %s", "--max-count=1", "bbbbbbb"}, "bbbbbbb the subject\n", nil) + instance := buildSubmoduleCommands(commonDeps{runner: runner}) + + summary, err := instance.GetCommitSummary("mysub", "bbbbbbb") + assert.NoError(t, err) + assert.Equal(t, "bbbbbbb the subject", summary) + runner.CheckForMissingCalls() +} + +func TestSubmoduleCheckoutConflictCommit(t *testing.T) { + runner := oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"-C", "mysub", "checkout", "bbbbbbb"}, "", nil) + instance := buildSubmoduleCommands(commonDeps{runner: runner}) + + assert.NoError(t, instance.CheckoutConflictCommit("mysub", "bbbbbbb")) + runner.CheckForMissingCalls() +} diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index c9034ec9cfd..12d6d007274 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -712,13 +712,20 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { // conflictNeedsResolutionDialog reports whether a file's merge conflict can only // be resolved through a dialog that picks one side, as opposed to editing -// conflict markers in the merge view. These are the "non-textual" conflicts, -// e.g. one side modified a file while the other deleted it (DD/AU/UA/UD/DU). +// conflict markers in the merge view. These are the "non-textual" conflicts: +// text files where one side modified and the other deleted/renamed the file +// (DD/AU/UA/UD/DU), and submodules where both sides moved the gitlink (UU). func (self *FilesController) conflictNeedsResolutionDialog(file *models.File) bool { if file == nil || !file.HasMergeConflicts { return false } + // A conflicted submodule has no conflict markers to edit; it's resolved by + // picking which commit to point at. + if file.IsSubmodule(self.c.Model().Submodules) { + return true + } + return !file.HasInlineMergeConflicts } @@ -743,7 +750,24 @@ func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *type return nil } +// isSubmoduleCommitConflict reports whether the file is a submodule whose commit +// pointer conflicts (status UU or AA): both sides recorded a different commit, +// with no base content to merge. These are resolved by picking one side's +// commit. Other submodule conflicts (e.g. modify/delete) are handled like +// ordinary non-textual conflicts, with the keep/delete picker. +func (self *FilesController) isSubmoduleCommitConflict(file *models.File) bool { + return file != nil && file.HasInlineMergeConflicts && file.IsSubmodule(self.c.Model().Submodules) +} + func (self *FilesController) openConflictResolutionMenu(file *models.File) error { + if self.isSubmoduleCommitConflict(file) { + return self.openSubmoduleConflictMenu(file) + } + + return self.openFileConflictMenu(file) +} + +func (self *FilesController) openFileConflictMenu(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 { @@ -790,6 +814,52 @@ func (self *FilesController) openConflictResolutionMenu(file *models.File) error }) } +func (self *FilesController) openSubmoduleConflictMenu(file *models.File) error { + path := file.GetPath() + _, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path) + if err != nil { + return err + } + + resolve := func(sha string, logAction string) error { + self.c.LogAction(logAction) + if err := self.c.Git().Submodule.CheckoutConflictCommit(path, sha); err != nil { + return err + } + if err := self.c.Git().WorkingTree.StageFile(path); err != nil { + return err + } + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) + return nil + } + + // Append the commit summary to the label so the user can tell the two + // candidates apart, falling back to the bare label if we can't read it. + label := func(text string, sha string) string { + if summary, err := self.c.Git().Submodule.GetCommitSummary(path, sha); err == nil && summary != "" { + return fmt.Sprintf("%s (%s)", text, summary) + } + return text + } + + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.MergeConflictsTitle, + Prompt: utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path}), + Items: []*types.MenuItem{ + { + Label: label(self.c.Tr.MergeConflictTakeCurrentCommit, ours), + OnPress: func() error { return resolve(ours, self.c.Tr.Actions.TakeCurrentSubmoduleCommit) }, + Keys: menuKey('c'), + }, + { + Label: label(self.c.Tr.MergeConflictTakeIncomingCommit, theirs), + OnPress: func() error { return resolve(theirs, self.c.Tr.Actions.TakeIncomingSubmoduleCommit) }, + Keys: menuKey('i'), + }, + }, + }) +} + 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 3928aac38c1..6ddf356cd0e 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -101,6 +101,9 @@ type TranslationSet struct { MergeConflictPressEnterToResolve string MergeConflictKeepFile string MergeConflictDeleteFile string + MergeConflictTakeCurrentCommit string + MergeConflictTakeIncomingCommit string + SubmoduleMergeConflictDescription string StageConflictsRangeDisabled string Checkout string CheckoutTooltip string @@ -1028,6 +1031,8 @@ type Actions struct { StageAllFiles string ResolveConflictByKeepingFile string ResolveConflictByDeletingFile string + TakeCurrentSubmoduleCommit string + TakeIncomingSubmoduleCommit string NotEnoughContextToStage string NotEnoughContextToDiscard string NotEnoughContextToRemoveLines string @@ -1201,6 +1206,9 @@ func EnglishTranslationSet() *TranslationSet { MergeConflictPressEnterToResolve: "Press %s to resolve.", MergeConflictKeepFile: "Keep file", MergeConflictDeleteFile: "Delete file", + MergeConflictTakeCurrentCommit: "Take current commit", + MergeConflictTakeIncomingCommit: "Take incoming commit", + SubmoduleMergeConflictDescription: "Conflict: the submodule '{{.path}}' was set to a different commit in the current and the incoming changes. Pick which commit to keep.", StageConflictsRangeDisabled: "Cannot stage a selection that includes files with merge conflicts; resolve them individually with {{.goIntoKey}} first.", Checkout: "Checkout", CheckoutTooltip: "Checkout selected item.", @@ -2116,6 +2124,8 @@ func EnglishTranslationSet() *TranslationSet { StageAllFiles: "Stage all files", ResolveConflictByKeepingFile: "Resolve by keeping file", ResolveConflictByDeletingFile: "Resolve by deleting file", + TakeCurrentSubmoduleCommit: "Resolve submodule conflict by taking current commit", + TakeIncomingSubmoduleCommit: "Resolve submodule conflict by taking incoming commit", 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'.", NotEnoughContextToRemoveLines: "Removing lines from a commit is not possible with a diff context size of 0. Increase the context using '%s'.", diff --git a/pkg/integration/tests/submodule/resolve_conflict.go b/pkg/integration/tests/submodule/resolve_conflict.go new file mode 100644 index 00000000000..fc589046caa --- /dev/null +++ b/pkg/integration/tests/submodule/resolve_conflict.go @@ -0,0 +1,73 @@ +package submodule + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ResolveConflict = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Resolve a submodule conflict (both sides moved the gitlink) by picking one side's commit", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path") + shell.GitAddAll() + shell.Commit("add submodule") + + sub := "my_submodule_path" + + // Two diverging commits in the submodule, so the gitlink can't be + // fast-forwarded and the merge genuinely conflicts. + shell.RunCommand([]string{"git", "-C", sub, "checkout", "-b", "left"}) + shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "left"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "-b", "right", "HEAD~1"}) + shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "right"}) + + // "ours" points the submodule at left, "theirs" at right. + shell.RunCommand([]string{"git", "checkout", "-b", "ours"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "left"}) + shell.RunCommand([]string{"git", "add", sub}) + shell.Commit("ours") + + shell.RunCommand([]string{"git", "checkout", "-b", "theirs", "HEAD~1"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "right"}) + shell.RunCommand([]string{"git", "add", sub}) + shell.Commit("theirs") + + shell.RunCommand([]string{"git", "checkout", "ours"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "left"}) + shell.RunCommandExpectError([]string{"git", "merge", "theirs"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Focus(). + Lines( + Contains("UU my_submodule_path (submodule)").IsSelected(), + ). + // Enter opens the resolution menu instead of entering the submodule. + // The two candidate commits are shown with their summaries. + Press(keys.Universal.GoInto). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Merge conflicts")). + Select(Contains("Take current commit").Contains("left")). + Select(Contains("Take incoming commit").Contains("right")). + Cancel() + }). + // Space opens the same menu; take the incoming commit to resolve. + PressPrimaryAction(). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Merge conflicts")). + Select(Contains("Take incoming commit")). + Confirm() + }). + Lines( + Contains("M my_submodule_path (submodule)").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index b2757736273..dc8d0ec6a33 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -430,6 +430,7 @@ var tests = []*components.IntegrationTest{ submodule.RemoveNested, submodule.Reset, submodule.ResetFolder, + submodule.ResolveConflict, submodule.Stage, submodule.StageAllWithDirtySubmodule, submodule.StageDirtyOnly, From 050225ffe6b594e5e28aa6bcdfbb447059a1fc82 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 14:10:37 +0200 Subject: [PATCH 5/6] Show per-side commit logs for submodule conflicts in the main view When a conflicted submodule is selected, the main view shows the commits each side added relative to their common ancestor as two indented logs, labelled current and incoming, so it's clear which commit each side would resolve to. The logs aren't truncated (the view scrolls). If a side added no commits of its own (e.g. it was rewound to an ancestor of the other), its head commit is shown instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_commands/submodule.go | 12 ++++ pkg/commands/git_commands/submodule_test.go | 11 ++++ pkg/gui/controllers/files_controller.go | 65 ++++++++++++++++--- .../tests/submodule/resolve_conflict.go | 9 +++ .../resolve_conflict_rewound_side.go | 63 ++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 6 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 pkg/integration/tests/submodule/resolve_conflict_rewound_side.go diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index d5852d7795e..3b88e4fbbc8 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -165,6 +165,18 @@ func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) e return self.cmd.New(cmdArgs).Run() } +// ConflictSideLog returns a oneline log, run inside the submodule, of the commits +// that `side` has but `otherSide` does not (i.e. `otherSide..side`) — the commits +// unique to one side of a commit conflict, relative to their common ancestor. It +// is empty if `side` is an ancestor of `otherSide` (e.g. that side was rewound). +func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSide string) (string, error) { + cmdArgs := NewGitCmd("log").Dir(path). + Arg("--oneline", "--color=always", otherSide+".."+side). + ToArgv() + + return self.cmd.New(cmdArgs).DontLog().RunWithOutput() +} + func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { // if the path does not exist then it hasn't yet been initialized so we'll swallow the error // because the intention here is to have no dirty worktree state diff --git a/pkg/commands/git_commands/submodule_test.go b/pkg/commands/git_commands/submodule_test.go index 4683b14cdda..279c963dfff 100644 --- a/pkg/commands/git_commands/submodule_test.go +++ b/pkg/commands/git_commands/submodule_test.go @@ -79,3 +79,14 @@ func TestSubmoduleCheckoutConflictCommit(t *testing.T) { assert.NoError(t, instance.CheckoutConflictCommit("mysub", "bbbbbbb")) runner.CheckForMissingCalls() } + +func TestSubmoduleConflictSideLog(t *testing.T) { + runner := oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"-C", "mysub", "log", "--oneline", "--color=always", "ccccccc..bbbbbbb"}, "bbbbbbb left\n", nil) + instance := buildSubmoduleCommands(commonDeps{runner: runner}) + + output, err := instance.ConflictSideLog("mysub", "bbbbbbb", "ccccccc") + assert.NoError(t, err) + assert.Equal(t, "bbbbbbb left\n", output) + runner.CheckForMissingCalls() +} diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 12d6d007274..8e7ca1a377a 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -270,6 +270,49 @@ func (self *FilesController) GetOnRenderToMain() func() { return } + if self.isSubmoduleCommitConflict(node.File) { + self.c.Helpers().MergeConflicts.ResetMergeState() + + path := node.GetPath() + _, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path) + if err != nil { + return + } + + // Show the commits each side added relative to their common + // ancestor as two separate, indented logs, so it's clear which is + // which. If a side added nothing of its own (e.g. it was rewound to + // an ancestor of the other), show the commit it points at instead. + sideBlock := func(header string, side string, otherSide string) string { + log, err := self.c.Git().Submodule.ConflictSideLog(path, side, otherSide) + if err != nil { + return header + } + if log = strings.TrimRight(log, "\n"); log == "" { + if log, err = self.c.Git().Submodule.GetCommitSummary(path, side); err != nil { + return header + } + } + return header + "\n\n " + strings.ReplaceAll(log, "\n", "\n ") + } + + message := strings.Join([]string{ + self.conflictResolutionHint(utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path})), + sideBlock(self.c.Tr.MergeConflictCurrentDiff, ours, theirs), + sideBlock(self.c.Tr.MergeConflictIncomingDiff, theirs, ours), + }, "\n\n") + + self.c.RenderToMainViews(types.RefreshMainOpts{ + Pair: self.c.MainViewPairs().Normal, + Main: &types.ViewUpdateOpts{ + Title: self.c.Tr.DiffTitle, + SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), + Task: types.NewRenderStringTask(message), + }, + }) + return + } + if node.File != nil && node.File.HasInlineMergeConflicts { hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath()) if err != nil { @@ -288,14 +331,7 @@ func (self *FilesController) GetOnRenderToMain() func() { SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), }, } - message := node.File.GetMergeStateDescription(self.c.Tr) - message += "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve, - self.c.UserConfig().Keybinding.Universal.GoInto) - if self.c.Views().Main.InnerWidth() > 70 { - // If the main view is very wide, wrap the message to increase readability - lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4) - message = strings.Join(lines, "\n") - } + message := self.conflictResolutionHint(node.File.GetMergeStateDescription(self.c.Tr)) if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" { cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()}) prefix := message + "\n\n" @@ -710,6 +746,19 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { return nil } +// conflictResolutionHint formats a conflict description for the main view, +// appending the "press to resolve" hint and wrapping it when the view is +// wide enough that long lines would otherwise hurt readability. +func (self *FilesController) conflictResolutionHint(description string) string { + message := description + "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve, + self.c.UserConfig().Keybinding.Universal.GoInto) + if self.c.Views().Main.InnerWidth() > 70 { + lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4) + message = strings.Join(lines, "\n") + } + return message +} + // conflictNeedsResolutionDialog reports whether a file's merge conflict can only // be resolved through a dialog that picks one side, as opposed to editing // conflict markers in the merge view. These are the "non-textual" conflicts: diff --git a/pkg/integration/tests/submodule/resolve_conflict.go b/pkg/integration/tests/submodule/resolve_conflict.go index fc589046caa..3623256248d 100644 --- a/pkg/integration/tests/submodule/resolve_conflict.go +++ b/pkg/integration/tests/submodule/resolve_conflict.go @@ -48,6 +48,15 @@ var ResolveConflict = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("UU my_submodule_path (submodule)").IsSelected(), ). + Tap(func() { + // The main view explains the conflict and shows each side's + // commits as separate "current" and "incoming" logs. + t.Views().Main().Content( + Contains("Conflict: the submodule"). + Contains("Current changes:").Contains("left"). + Contains("Incoming changes:").Contains("right"), + ) + }). // Enter opens the resolution menu instead of entering the submodule. // The two candidate commits are shown with their summaries. Press(keys.Universal.GoInto). diff --git a/pkg/integration/tests/submodule/resolve_conflict_rewound_side.go b/pkg/integration/tests/submodule/resolve_conflict_rewound_side.go new file mode 100644 index 00000000000..06f0e8f2e44 --- /dev/null +++ b/pkg/integration/tests/submodule/resolve_conflict_rewound_side.go @@ -0,0 +1,63 @@ +package submodule + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ResolveConflictRewoundSide = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "When a side of a submodule conflict added no commits of its own (it was rewound), the main view shows the commit it points at instead of an empty log", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("sub_name", "sub_path") + shell.GitAddAll() + shell.Commit("add submodule") + + sub := "sub_path" + + // Mark the submodule's initial commit, then advance it; the merge base + // will point the submodule here. + shell.RunCommand([]string{"git", "-C", sub, "branch", "initial"}) + shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "s1"}) + shell.RunCommand([]string{"git", "add", sub}) + shell.Commit("base at s1") + + // "ours" rewinds the submodule to its initial commit (so it has no + // commits of its own relative to "theirs"). + shell.RunCommand([]string{"git", "checkout", "-b", "ours"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "initial"}) + shell.RunCommand([]string{"git", "add", sub}) + shell.Commit("ours rewinds submodule") + + // "theirs" advances the submodule with a further commit. + shell.RunCommand([]string{"git", "checkout", "-b", "theirs", "HEAD~1"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "master"}) + shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "s2"}) + shell.RunCommand([]string{"git", "add", sub}) + shell.Commit("theirs advances submodule") + + shell.RunCommand([]string{"git", "checkout", "ours"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "initial"}) + shell.RunCommandExpectError([]string{"git", "merge", "theirs"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Focus(). + Lines( + Contains("UU sub_path (submodule)").IsSelected(), + ). + Tap(func() { + // "ours" has no commits of its own, so its section falls back to + // the commit it points at; "theirs" lists the commits it added. + t.Views().Main().Content( + Contains("Current changes:").Contains("first commit"). + Contains("Incoming changes:").Contains("s1").Contains("s2"), + ) + }) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index dc8d0ec6a33..d7d5d66fa81 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -431,6 +431,7 @@ var tests = []*components.IntegrationTest{ submodule.Reset, submodule.ResetFolder, submodule.ResolveConflict, + submodule.ResolveConflictRewoundSide, submodule.Stage, submodule.StageAllWithDirtySubmodule, submodule.StageDirtyOnly, From 71a6396275eb895fbeb39c5abb84d32f9c656863 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 16:24:02 +0200 Subject: [PATCH 6/6] Extract per-case render helpers from FilesController.GetOnRenderToMain GetOnRenderToMain had grown to handle five distinct rendering cases inline (no selection, submodule conflict, inline text conflict, non-textual text conflict, and the normal working-tree diff), which made it hard to follow. Split each case into its own method so the function reads as a short dispatcher, and pull the repeated main-view boilerplate into renderToMainWithTask. Pure refactor; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/files_controller.go | 233 +++++++++++++----------- 1 file changed, 127 insertions(+), 106 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 8e7ca1a377a..a63c6a15a3e 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -259,134 +259,155 @@ func (self *FilesController) GetOnRenderToMain() func() { node := self.context().GetSelected() if node == nil { - self.c.RenderToMainViews(types.RefreshMainOpts{ - Pair: self.c.MainViewPairs().Normal, - Main: &types.ViewUpdateOpts{ - Title: self.c.Tr.DiffTitle, - SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - Task: types.NewRenderStringTask(self.c.Tr.NoChangedFiles), - }, - }) + self.renderToMainWithTask(types.NewRenderStringTask(self.c.Tr.NoChangedFiles)) return } if self.isSubmoduleCommitConflict(node.File) { - self.c.Helpers().MergeConflicts.ResetMergeState() - - path := node.GetPath() - _, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path) - if err != nil { - return - } - - // Show the commits each side added relative to their common - // ancestor as two separate, indented logs, so it's clear which is - // which. If a side added nothing of its own (e.g. it was rewound to - // an ancestor of the other), show the commit it points at instead. - sideBlock := func(header string, side string, otherSide string) string { - log, err := self.c.Git().Submodule.ConflictSideLog(path, side, otherSide) - if err != nil { - return header - } - if log = strings.TrimRight(log, "\n"); log == "" { - if log, err = self.c.Git().Submodule.GetCommitSummary(path, side); err != nil { - return header - } - } - return header + "\n\n " + strings.ReplaceAll(log, "\n", "\n ") - } - - message := strings.Join([]string{ - self.conflictResolutionHint(utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path})), - sideBlock(self.c.Tr.MergeConflictCurrentDiff, ours, theirs), - sideBlock(self.c.Tr.MergeConflictIncomingDiff, theirs, ours), - }, "\n\n") - - self.c.RenderToMainViews(types.RefreshMainOpts{ - Pair: self.c.MainViewPairs().Normal, - Main: &types.ViewUpdateOpts{ - Title: self.c.Tr.DiffTitle, - SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - Task: types.NewRenderStringTask(message), - }, - }) + self.renderSubmoduleConflict(node) return } if node.File != nil && node.File.HasInlineMergeConflicts { - hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath()) - if err != nil { - return - } - - if hasConflicts { - self.c.Helpers().MergeConflicts.Render() + if self.renderInlineMergeConflict(node) { return } + // The file is marked as conflicted but has no conflict markers (it + // was resolved in an editor), so fall through to show its diff. } else if node.File != nil && node.File.HasMergeConflicts { - opts := types.RefreshMainOpts{ - Pair: self.c.MainViewPairs().Normal, - Main: &types.ViewUpdateOpts{ - Title: self.c.Tr.DiffTitle, - SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - }, - } - message := self.conflictResolutionHint(node.File.GetMergeStateDescription(self.c.Tr)) - if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" { - cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()}) - prefix := message + "\n\n" - if node.File.ShortStatus == "DU" { - prefix += self.c.Tr.MergeConflictIncomingDiff - } else { - prefix += self.c.Tr.MergeConflictCurrentDiff - } - prefix += "\n\n" - opts.Main.Task = types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix) - } else { - opts.Main.Task = types.NewRenderStringTask(message) - } - self.c.RenderToMainViews(opts) + self.renderNonTextualConflict(node) return } - self.c.Helpers().MergeConflicts.ResetMergeState() + self.renderWorkingTreeDiff(node) + }) + } +} - split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges()) - mainShowsStaged := !split && node.GetHasStagedChanges() +// renderToMainWithTask renders the given task to the main view with the standard +// diff title and subtitle. +func (self *FilesController) renderToMainWithTask(task types.UpdateTask) { + self.c.RenderToMainViews(types.RefreshMainOpts{ + Pair: self.c.MainViewPairs().Normal, + Main: &types.ViewUpdateOpts{ + Title: self.c.Tr.DiffTitle, + SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), + Task: task, + }, + }) +} - pathOverrides := self.pathOverridesForDiff(node) - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides) - title := self.c.Tr.UnstagedChanges - if mainShowsStaged { - title = self.c.Tr.StagedChanges - } - refreshOpts := types.RefreshMainOpts{ - Pair: self.c.MainViewPairs().Normal, - Main: &types.ViewUpdateOpts{ - Task: types.NewRunPtyTask(cmdObj.GetCmd()), - SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - Title: title, - }, +// renderSubmoduleConflict shows, for a conflicted submodule, an explanation plus +// the commits each side added relative to their common ancestor as two separate, +// indented logs. If a side added nothing of its own (e.g. it was rewound to an +// ancestor of the other), the commit it points at is shown instead. +func (self *FilesController) renderSubmoduleConflict(node *filetree.FileNode) { + self.c.Helpers().MergeConflicts.ResetMergeState() + + path := node.GetPath() + _, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path) + if err != nil { + return + } + + sideBlock := func(header string, side string, otherSide string) string { + log, err := self.c.Git().Submodule.ConflictSideLog(path, side, otherSide) + if err != nil { + return header + } + if log = strings.TrimRight(log, "\n"); log == "" { + if log, err = self.c.Git().Submodule.GetCommitSummary(path, side); err != nil { + return header } + } + return header + "\n\n " + strings.ReplaceAll(log, "\n", "\n ") + } - if split { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides) + message := strings.Join([]string{ + self.conflictResolutionHint(utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path})), + sideBlock(self.c.Tr.MergeConflictCurrentDiff, ours, theirs), + sideBlock(self.c.Tr.MergeConflictIncomingDiff, theirs, ours), + }, "\n\n") - title := self.c.Tr.StagedChanges - if mainShowsStaged { - title = self.c.Tr.UnstagedChanges - } + self.renderToMainWithTask(types.NewRenderStringTask(message)) +} - refreshOpts.Secondary = &types.ViewUpdateOpts{ - Title: title, - SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - Task: types.NewRunPtyTask(cmdObj.GetCmd()), - } - } +// renderInlineMergeConflict renders the merge-conflict view for a file with +// inline conflict markers. It returns false if the file has no actual markers +// (it was resolved in an editor), in which case the caller should fall back to +// showing the file's diff. +func (self *FilesController) renderInlineMergeConflict(node *filetree.FileNode) bool { + hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath()) + if err != nil { + return true + } - self.c.RenderToMainViews(refreshOpts) - }) + if !hasConflicts { + return false } + + self.c.Helpers().MergeConflicts.Render() + return true +} + +// renderNonTextualConflict shows the resolution hint for a non-textual text-file +// conflict (DD/AU/UA/UD/DU), plus the base diff for the modify/delete cases. +func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) { + message := self.conflictResolutionHint(node.File.GetMergeStateDescription(self.c.Tr)) + + if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" { + cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()}) + prefix := message + "\n\n" + if node.File.ShortStatus == "DU" { + prefix += self.c.Tr.MergeConflictIncomingDiff + } else { + prefix += self.c.Tr.MergeConflictCurrentDiff + } + prefix += "\n\n" + self.renderToMainWithTask(types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)) + return + } + + self.renderToMainWithTask(types.NewRenderStringTask(message)) +} + +func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { + self.c.Helpers().MergeConflicts.ResetMergeState() + + split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges()) + mainShowsStaged := !split && node.GetHasStagedChanges() + + pathOverrides := self.pathOverridesForDiff(node) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides) + title := self.c.Tr.UnstagedChanges + if mainShowsStaged { + title = self.c.Tr.StagedChanges + } + refreshOpts := types.RefreshMainOpts{ + Pair: self.c.MainViewPairs().Normal, + Main: &types.ViewUpdateOpts{ + Task: types.NewRunPtyTask(cmdObj.GetCmd()), + SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), + Title: title, + }, + } + + if split { + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides) + + title := self.c.Tr.StagedChanges + if mainShowsStaged { + title = self.c.Tr.UnstagedChanges + } + + refreshOpts.Secondary = &types.ViewUpdateOpts{ + Title: title, + SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), + Task: types.NewRunPtyTask(cmdObj.GetCmd()), + } + } + + self.c.RenderToMainViews(refreshOpts) } func (self *FilesController) GetOnDoubleClick() func() error {