From 050225ffe6b594e5e28aa6bcdfbb447059a1fc82 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 14:10:37 +0200 Subject: [PATCH] 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 d5852d779..3b88e4fbb 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 4683b14cd..279c963df 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 12d6d0072..8e7ca1a37 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 fc589046c..362325624 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 000000000..06f0e8f2e --- /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 dc8d0ec6a..d7d5d66fa 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,