diff --git a/AGENTS.md b/AGENTS.md index c379b303032..50b808fb40e 100644 --- a/AGENTS.md +++ b/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 diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index 7a3cb687bc9..3b88e4fbbc8 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -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 " \t". + 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 " " 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 diff --git a/pkg/commands/git_commands/submodule_test.go b/pkg/commands/git_commands/submodule_test.go new file mode 100644 index 00000000000..279c963dfff --- /dev/null +++ b/pkg/commands/git_commands/submodule_test.go @@ -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() +} diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index d048da50897..a63c6a15a3e 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -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,98 +259,155 @@ 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 node.File != nil && node.File.HasInlineMergeConflicts { - hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath()) - if err != nil { - return - } + if self.isSubmoduleCommitConflict(node.File) { + self.renderSubmoduleConflict(node) + return + } - if hasConflicts { - self.c.Helpers().MergeConflicts.Render() + if node.File != nil && node.File.HasInlineMergeConflicts { + 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() + self.renderWorkingTreeDiff(node) + }) + } +} - split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges()) - mainShowsStaged := !split && node.GetHasStagedChanges() +// 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, + }, + }) +} - 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, - }, +// 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 ") + } - if split { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides) + 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") - title := self.c.Tr.StagedChanges - if mainShowsStaged { - title = self.c.Tr.UnstagedChanges - } + self.renderToMainWithTask(types.NewRenderStringTask(message)) +} - refreshOpts.Secondary = &types.ViewUpdateOpts{ - Title: title, - SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - Task: types.NewRunPtyTask(cmdObj.GetCmd()), - } - } +// 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 + } - self.c.RenderToMainViews(refreshOpts) - }) + 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 { @@ -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 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 diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 8dd40016af9..6ddf356cd0e 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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'.", diff --git a/pkg/integration/tests/conflicts/space_on_non_textual_conflict.go b/pkg/integration/tests/conflicts/space_on_non_textual_conflict.go new file mode 100644 index 00000000000..2899fadffe4 --- /dev/null +++ b/pkg/integration/tests/conflicts/space_on_non_textual_conflict.go @@ -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")) + }) + }, +}) diff --git a/pkg/integration/tests/submodule/resolve_conflict.go b/pkg/integration/tests/submodule/resolve_conflict.go new file mode 100644 index 00000000000..3623256248d --- /dev/null +++ b/pkg/integration/tests/submodule/resolve_conflict.go @@ -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(), + ) + }, +}) 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 00000000000..06f0e8f2e44 --- /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 3cadf6843e2..d7d5d66fa81 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -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,