From afe4d14106adcffe81f5f04dbbe94578dc0881d1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 14:04:07 +0200 Subject: [PATCH] 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 7a3cb687b..d5852d779 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 000000000..4683b14cd --- /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 c9034ec9c..12d6d0072 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 3928aac38..6ddf356cd 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 000000000..fc589046c --- /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 b27577362..dc8d0ec6a 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,