mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
afe4d14106
commit
050225ffe6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
@ -431,6 +431,7 @@ var tests = []*components.IntegrationTest{
|
|||
submodule.Reset,
|
||||
submodule.ResetFolder,
|
||||
submodule.ResolveConflict,
|
||||
submodule.ResolveConflictRewoundSide,
|
||||
submodule.Stage,
|
||||
submodule.StageAllWithDirtySubmodule,
|
||||
submodule.StageDirtyOnly,
|
||||
|
|
|
|||
Loading…
Reference in a new issue