mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
860f89e0c9
commit
afe4d14106
|
|
@ -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 "<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()
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
81
pkg/commands/git_commands/submodule_test.go
Normal file
81
pkg/commands/git_commands/submodule_test.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'.",
|
||||
|
|
|
|||
73
pkg/integration/tests/submodule/resolve_conflict.go
Normal file
73
pkg/integration/tests/submodule/resolve_conflict.go
Normal file
|
|
@ -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(),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
|
@ -430,6 +430,7 @@ var tests = []*components.IntegrationTest{
|
|||
submodule.RemoveNested,
|
||||
submodule.Reset,
|
||||
submodule.ResetFolder,
|
||||
submodule.ResolveConflict,
|
||||
submodule.Stage,
|
||||
submodule.StageAllWithDirtySubmodule,
|
||||
submodule.StageDirtyOnly,
|
||||
|
|
|
|||
Loading…
Reference in a new issue