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,