Improve resolving non-textual and submodule merge conflicts (#5735)

For a submodule conflict (where the two branches point the submodule at
different commits, resulting in a `UU` status), pressing space would
show a confusing "Nothing to stage…" error, and enter just stepped into
the submodule; neither is helpful for resolving the conflict. Now both
space and enter open a dialog where you can pick the current commit or
the incoming commit, each shown with its head commit subject so you know
what you're choosing. And the main panel explains the conflict and lists
the commits each side added, so you can see how they diverged before
deciding.

This now behaves very similarly to the way you resolve non-textual
conflicts for text files (e.g. `AU` or `UD`). Speaking of which: for
these it was still possible to press space to stage them, which simply
ran `git add`, whose behavior in this case is to keep the file. This is
neither obvious nor symmetric, so we change this to route space to the
conflict picker too, like enter.
This commit is contained in:
Stefan Haller 2026-06-28 16:47:11 +02:00 committed by GitHub
commit b31e572547
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 663 additions and 85 deletions

View file

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

View file

@ -111,6 +111,72 @@ 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 "<mode> <sha> <stage>\t<path>".
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 "<short-sha> <subject>" 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()
}
// 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

View file

@ -0,0 +1,92 @@
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()
}
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()
}

View file

@ -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,
@ -259,100 +259,157 @@ 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.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 := 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")
}
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()
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)
self.renderWorkingTreeDiff(node)
})
}
}
// 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,
},
})
}
// 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 ")
}
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.renderToMainWithTask(types.NewRenderStringTask(message))
}
// 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
}
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 {
return self.withItemGraceful(func(node *filetree.FileNode) error {
return self.press([]*filetree.FileNode{node})
@ -583,6 +640,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
}
@ -683,6 +746,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 +759,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 +767,77 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
return nil
}
func (self *FilesController) handleNonInlineConflict(file *models.File) error {
// conflictResolutionHint formats a conflict description for the main view,
// appending the "press <enter> 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:
// 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
}
// 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
}
// 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 {
@ -750,6 +884,52 @@ func (self *FilesController) handleNonInlineConflict(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

View file

@ -101,6 +101,10 @@ type TranslationSet struct {
MergeConflictPressEnterToResolve string
MergeConflictKeepFile string
MergeConflictDeleteFile string
MergeConflictTakeCurrentCommit string
MergeConflictTakeIncomingCommit string
SubmoduleMergeConflictDescription string
StageConflictsRangeDisabled string
Checkout string
CheckoutTooltip string
CantCheckoutBranchWhilePulling string
@ -1027,6 +1031,8 @@ type Actions struct {
StageAllFiles string
ResolveConflictByKeepingFile string
ResolveConflictByDeletingFile string
TakeCurrentSubmoduleCommit string
TakeIncomingSubmoduleCommit string
NotEnoughContextToStage string
NotEnoughContextToDiscard string
NotEnoughContextToRemoveLines string
@ -1200,6 +1206,10 @@ 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.",
CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch",
@ -2114,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'.",

View file

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

View file

@ -0,0 +1,82 @@
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(),
).
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).
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(),
)
},
})

View file

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

View file

@ -172,6 +172,7 @@ var tests = []*components.IntegrationTest{
conflicts.ResolveNoAutoStage,
conflicts.ResolveNonTextualConflicts,
conflicts.ResolveWithoutTrailingLf,
conflicts.SpaceOnNonTextualConflict,
conflicts.UndoChooseHunk,
custom_commands.AccessCommitProperties,
custom_commands.BasicCommand,
@ -429,6 +430,8 @@ var tests = []*components.IntegrationTest{
submodule.RemoveNested,
submodule.Reset,
submodule.ResetFolder,
submodule.ResolveConflict,
submodule.ResolveConflictRewoundSide,
submodule.Stage,
submodule.StageAllWithDirtySubmodule,
submodule.StageDirtyOnly,