mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
Improve resolving non-textual and submodule merge conflicts (#5735)
For a submodule conflict (where the two branches point the submodule at different commits, resulting in a `UU` status), pressing space would show a confusing "Nothing to stage…" error, and enter just stepped into the submodule; neither is helpful for resolving the conflict. Now both space and enter open a dialog where you can pick the current commit or the incoming commit, each shown with its head commit subject so you know what you're choosing. And the main panel explains the conflict and lists the commits each side added, so you can see how they diverged before deciding. This now behaves very similarly to the way you resolve non-textual conflicts for text files (e.g. `AU` or `UD`). Speaking of which: for these it was still possible to press space to stage them, which simply ran `git add`, whose behavior in this case is to keep the file. This is neither obvious nor symmetric, so we change this to route space to the conflict picker too, like enter.
This commit is contained in:
commit
b31e572547
24
AGENTS.md
24
AGENTS.md
|
|
@ -252,6 +252,30 @@ keep the call site fluent.
|
|||
Prefer `assert.Equal` (and friends) over hand-rolled `if` checks. The failure
|
||||
messages are more useful and the intent is clearer at a glance.
|
||||
|
||||
## Translatable strings use Go templates, not `%s`
|
||||
|
||||
Never put `fmt.Sprintf`-style placeholders (`%s`, `%d`, …) in translatable
|
||||
strings — the fields of `TranslationSet` and `Actions` in
|
||||
`pkg/i18n/english.go`. Use named Go-template placeholders and fill them in with
|
||||
`utils.ResolvePlaceholderString`:
|
||||
|
||||
```go
|
||||
// in english.go
|
||||
DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?",
|
||||
|
||||
// at the call site
|
||||
utils.ResolvePlaceholderString(
|
||||
self.c.Tr.DeleteBranchTitle,
|
||||
map[string]string{"selectedBranchName": branchName},
|
||||
)
|
||||
```
|
||||
|
||||
Named placeholders tell localizers what each value is (a bare `%s` says
|
||||
nothing, and translators can't safely reorder positional verbs across
|
||||
languages), and the map form extends cleanly when a string later needs more
|
||||
than one placeholder. This holds for every user-facing string, including short
|
||||
ones like disabled-action reasons and toasts.
|
||||
|
||||
## Code comments are for future readers, not development history
|
||||
|
||||
Comments in source code explain *why this code is shaped the way it is*. They
|
||||
|
|
|
|||
|
|
@ -111,6 +111,72 @@ 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()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
92
pkg/commands/git_commands/submodule_test.go
Normal file
92
pkg/commands/git_commands/submodule_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
|
|||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Select),
|
||||
Handler: self.withItems(self.press),
|
||||
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected())),
|
||||
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canStageSelection))),
|
||||
Description: self.c.Tr.Stage,
|
||||
Tooltip: self.c.Tr.StageTooltip,
|
||||
DisplayOnScreen: true,
|
||||
|
|
@ -259,100 +259,157 @@ func (self *FilesController) GetOnRenderToMain() func() {
|
|||
node := self.context().GetSelected()
|
||||
|
||||
if node == nil {
|
||||
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(self.c.Tr.NoChangedFiles),
|
||||
},
|
||||
})
|
||||
self.renderToMainWithTask(types.NewRenderStringTask(self.c.Tr.NoChangedFiles))
|
||||
return
|
||||
}
|
||||
|
||||
if self.isSubmoduleCommitConflict(node.File) {
|
||||
self.renderSubmoduleConflict(node)
|
||||
return
|
||||
}
|
||||
|
||||
if node.File != nil && node.File.HasInlineMergeConflicts {
|
||||
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if hasConflicts {
|
||||
self.c.Helpers().MergeConflicts.Render()
|
||||
if self.renderInlineMergeConflict(node) {
|
||||
return
|
||||
}
|
||||
// The file is marked as conflicted but has no conflict markers (it
|
||||
// was resolved in an editor), so fall through to show its diff.
|
||||
} else if node.File != nil && node.File.HasMergeConflicts {
|
||||
opts := types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
Title: self.c.Tr.DiffTitle,
|
||||
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")
|
||||
}
|
||||
if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" {
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()})
|
||||
prefix := message + "\n\n"
|
||||
if node.File.ShortStatus == "DU" {
|
||||
prefix += self.c.Tr.MergeConflictIncomingDiff
|
||||
} else {
|
||||
prefix += self.c.Tr.MergeConflictCurrentDiff
|
||||
}
|
||||
prefix += "\n\n"
|
||||
opts.Main.Task = types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
} else {
|
||||
opts.Main.Task = types.NewRenderStringTask(message)
|
||||
}
|
||||
self.c.RenderToMainViews(opts)
|
||||
self.renderNonTextualConflict(node)
|
||||
return
|
||||
}
|
||||
|
||||
self.c.Helpers().MergeConflicts.ResetMergeState()
|
||||
|
||||
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
|
||||
mainShowsStaged := !split && node.GetHasStagedChanges()
|
||||
|
||||
pathOverrides := self.pathOverridesForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
|
||||
title := self.c.Tr.UnstagedChanges
|
||||
if mainShowsStaged {
|
||||
title = self.c.Tr.StagedChanges
|
||||
}
|
||||
refreshOpts := types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Title: title,
|
||||
},
|
||||
}
|
||||
|
||||
if split {
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
|
||||
|
||||
title := self.c.Tr.StagedChanges
|
||||
if mainShowsStaged {
|
||||
title = self.c.Tr.UnstagedChanges
|
||||
}
|
||||
|
||||
refreshOpts.Secondary = &types.ViewUpdateOpts{
|
||||
Title: title,
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
}
|
||||
}
|
||||
|
||||
self.c.RenderToMainViews(refreshOpts)
|
||||
self.renderWorkingTreeDiff(node)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// renderToMainWithTask renders the given task to the main view with the standard
|
||||
// diff title and subtitle.
|
||||
func (self *FilesController) renderToMainWithTask(task types.UpdateTask) {
|
||||
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: task,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// renderSubmoduleConflict shows, for a conflicted submodule, an explanation plus
|
||||
// the commits each side added relative to their common ancestor as two separate,
|
||||
// indented logs. If a side added nothing of its own (e.g. it was rewound to an
|
||||
// ancestor of the other), the commit it points at is shown instead.
|
||||
func (self *FilesController) renderSubmoduleConflict(node *filetree.FileNode) {
|
||||
self.c.Helpers().MergeConflicts.ResetMergeState()
|
||||
|
||||
path := node.GetPath()
|
||||
_, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
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.renderToMainWithTask(types.NewRenderStringTask(message))
|
||||
}
|
||||
|
||||
// renderInlineMergeConflict renders the merge-conflict view for a file with
|
||||
// inline conflict markers. It returns false if the file has no actual markers
|
||||
// (it was resolved in an editor), in which case the caller should fall back to
|
||||
// showing the file's diff.
|
||||
func (self *FilesController) renderInlineMergeConflict(node *filetree.FileNode) bool {
|
||||
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if !hasConflicts {
|
||||
return false
|
||||
}
|
||||
|
||||
self.c.Helpers().MergeConflicts.Render()
|
||||
return true
|
||||
}
|
||||
|
||||
// renderNonTextualConflict shows the resolution hint for a non-textual text-file
|
||||
// conflict (DD/AU/UA/UD/DU), plus the base diff for the modify/delete cases.
|
||||
func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
|
||||
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"
|
||||
if node.File.ShortStatus == "DU" {
|
||||
prefix += self.c.Tr.MergeConflictIncomingDiff
|
||||
} else {
|
||||
prefix += self.c.Tr.MergeConflictCurrentDiff
|
||||
}
|
||||
prefix += "\n\n"
|
||||
self.renderToMainWithTask(types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix))
|
||||
return
|
||||
}
|
||||
|
||||
self.renderToMainWithTask(types.NewRenderStringTask(message))
|
||||
}
|
||||
|
||||
func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
||||
self.c.Helpers().MergeConflicts.ResetMergeState()
|
||||
|
||||
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
|
||||
mainShowsStaged := !split && node.GetHasStagedChanges()
|
||||
|
||||
pathOverrides := self.pathOverridesForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
|
||||
title := self.c.Tr.UnstagedChanges
|
||||
if mainShowsStaged {
|
||||
title = self.c.Tr.StagedChanges
|
||||
}
|
||||
refreshOpts := types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Title: title,
|
||||
},
|
||||
}
|
||||
|
||||
if split {
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
|
||||
|
||||
title := self.c.Tr.StagedChanges
|
||||
if mainShowsStaged {
|
||||
title = self.c.Tr.UnstagedChanges
|
||||
}
|
||||
|
||||
refreshOpts.Secondary = &types.ViewUpdateOpts{
|
||||
Title: title,
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
}
|
||||
}
|
||||
|
||||
self.c.RenderToMainViews(refreshOpts)
|
||||
}
|
||||
|
||||
func (self *FilesController) GetOnDoubleClick() func() error {
|
||||
return self.withItemGraceful(func(node *filetree.FileNode) error {
|
||||
return self.press([]*filetree.FileNode{node})
|
||||
|
|
@ -583,6 +640,12 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e
|
|||
}
|
||||
|
||||
func (self *FilesController) press(nodes []*filetree.FileNode) error {
|
||||
// A single file with a conflict that can only be resolved through a dialog
|
||||
// can't be staged; route it to the same picker that `enter` uses instead.
|
||||
if len(nodes) == 1 && self.conflictNeedsResolutionDialog(nodes[0].File) {
|
||||
return self.openConflictResolutionMenu(nodes[0].File)
|
||||
}
|
||||
|
||||
if err := self.pressWithLock(nodes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -683,6 +746,10 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
|
|||
|
||||
file := node.File
|
||||
|
||||
if self.conflictNeedsResolutionDialog(file) {
|
||||
return self.openConflictResolutionMenu(file)
|
||||
}
|
||||
|
||||
submoduleConfigs := self.c.Model().Submodules
|
||||
if file.IsSubmodule(submoduleConfigs) {
|
||||
submoduleConfig := file.SubmoduleConfig(submoduleConfigs)
|
||||
|
|
@ -692,9 +759,6 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
|
|||
if file.HasInlineMergeConflicts {
|
||||
return self.switchToMerge()
|
||||
}
|
||||
if file.HasMergeConflicts {
|
||||
return self.handleNonInlineConflict(file)
|
||||
}
|
||||
|
||||
context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging)
|
||||
self.c.Context().Push(context, opts)
|
||||
|
|
@ -703,7 +767,77 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (self *FilesController) handleNonInlineConflict(file *models.File) error {
|
||||
// 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:
|
||||
// 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
|
||||
}
|
||||
|
||||
// canStageSelection disables staging when a multiple selection includes a file
|
||||
// with a conflict that must be resolved through a dialog; those have to be
|
||||
// resolved one at a time.
|
||||
func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *types.DisabledReason {
|
||||
if len(nodes) > 1 {
|
||||
for _, node := range nodes {
|
||||
if node.SomeFile(self.conflictNeedsResolutionDialog) {
|
||||
return &types.DisabledReason{
|
||||
Text: utils.ResolvePlaceholderString(
|
||||
self.c.Tr.StageConflictsRangeDisabled, map[string]string{
|
||||
"goIntoKey": self.c.UserConfig().Keybinding.Universal.GoInto.String(),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -750,6 +884,52 @@ func (self *FilesController) handleNonInlineConflict(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,10 @@ type TranslationSet struct {
|
|||
MergeConflictPressEnterToResolve string
|
||||
MergeConflictKeepFile string
|
||||
MergeConflictDeleteFile string
|
||||
MergeConflictTakeCurrentCommit string
|
||||
MergeConflictTakeIncomingCommit string
|
||||
SubmoduleMergeConflictDescription string
|
||||
StageConflictsRangeDisabled string
|
||||
Checkout string
|
||||
CheckoutTooltip string
|
||||
CantCheckoutBranchWhilePulling string
|
||||
|
|
@ -1027,6 +1031,8 @@ type Actions struct {
|
|||
StageAllFiles string
|
||||
ResolveConflictByKeepingFile string
|
||||
ResolveConflictByDeletingFile string
|
||||
TakeCurrentSubmoduleCommit string
|
||||
TakeIncomingSubmoduleCommit string
|
||||
NotEnoughContextToStage string
|
||||
NotEnoughContextToDiscard string
|
||||
NotEnoughContextToRemoveLines string
|
||||
|
|
@ -1200,6 +1206,10 @@ 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.",
|
||||
CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch",
|
||||
|
|
@ -2114,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'.",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
package conflicts
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var SpaceOnNonTextualConflict = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Pressing space on a non-textual conflict opens the resolution menu; staging is disabled for a range that includes one",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.ShowFileTree = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.RunShellCommand(`echo 1 > foo && echo 1 > bar`)
|
||||
shell.RunShellCommand(`git checkout -b base && git add . && git commit -m base`)
|
||||
|
||||
// theirs: delete foo, modify bar
|
||||
shell.RunShellCommand(`git checkout -b theirs`)
|
||||
shell.RunShellCommand(`git rm foo && echo 2 > bar && git add bar && git commit -m theirs`)
|
||||
|
||||
// ours: modify foo, delete bar
|
||||
shell.RunShellCommand(`git checkout base && git checkout -b ours`)
|
||||
shell.RunShellCommand(`echo 2 > foo && git add foo && git rm bar && git commit -m ours`)
|
||||
|
||||
shell.RunCommandExpectError([]string{"git", "merge", "theirs"})
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("DU bar"),
|
||||
Contains("UD foo"),
|
||||
).
|
||||
// Pressing space on a single non-textual conflict opens the
|
||||
// resolution menu rather than trying to stage it.
|
||||
NavigateToLine(Contains("bar")).
|
||||
PressPrimaryAction().
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Menu().Title(Equals("Merge conflicts")).Cancel()
|
||||
}).
|
||||
// Staging is disabled for a range selection that includes a conflict.
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
NavigateToLine(Contains("foo")).
|
||||
PressPrimaryAction().
|
||||
Tap(func() {
|
||||
t.ExpectToast(Contains("Cannot stage a selection that includes files with merge conflicts"))
|
||||
}).
|
||||
// Entering a range selection is disabled too, with the usual toast.
|
||||
Press(keys.Universal.GoInto).
|
||||
Tap(func() {
|
||||
t.ExpectToast(Contains("does not support range selection"))
|
||||
})
|
||||
},
|
||||
})
|
||||
82
pkg/integration/tests/submodule/resolve_conflict.go
Normal file
82
pkg/integration/tests/submodule/resolve_conflict.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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(),
|
||||
).
|
||||
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).
|
||||
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(),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
|
@ -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"),
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
@ -172,6 +172,7 @@ var tests = []*components.IntegrationTest{
|
|||
conflicts.ResolveNoAutoStage,
|
||||
conflicts.ResolveNonTextualConflicts,
|
||||
conflicts.ResolveWithoutTrailingLf,
|
||||
conflicts.SpaceOnNonTextualConflict,
|
||||
conflicts.UndoChooseHunk,
|
||||
custom_commands.AccessCommitProperties,
|
||||
custom_commands.BasicCommand,
|
||||
|
|
@ -429,6 +430,8 @@ var tests = []*components.IntegrationTest{
|
|||
submodule.RemoveNested,
|
||||
submodule.Reset,
|
||||
submodule.ResetFolder,
|
||||
submodule.ResolveConflict,
|
||||
submodule.ResolveConflictRewoundSide,
|
||||
submodule.Stage,
|
||||
submodule.StageAllWithDirtySubmodule,
|
||||
submodule.StageDirtyOnly,
|
||||
|
|
|
|||
Loading…
Reference in a new issue