mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-03-31 22:22:14 +02:00
improve merge conflict flow
This commit is contained in:
parent
ce3bcfe37c
commit
c8cc18920f
@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
@ -40,8 +41,16 @@ func (self *WorkingTreeCommands) OpenMergeTool() error {
|
||||
}
|
||||
|
||||
// StageFile stages a file
|
||||
func (self *WorkingTreeCommands) StageFile(fileName string) error {
|
||||
return self.cmd.New("git add -- " + self.cmd.Quote(fileName)).Run()
|
||||
func (self *WorkingTreeCommands) StageFile(path string) error {
|
||||
return self.StageFiles([]string{path})
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) StageFiles(paths []string) error {
|
||||
quotedPaths := make([]string, len(paths))
|
||||
for i, path := range paths {
|
||||
quotedPaths[i] = self.cmd.Quote(path)
|
||||
}
|
||||
return self.cmd.New(fmt.Sprintf("git add -- %s", strings.Join(quotedPaths, " "))).Run()
|
||||
}
|
||||
|
||||
// StageAll stages all files
|
||||
|
@ -23,6 +23,16 @@ func TestWorkingTreeStageFile(t *testing.T) {
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestWorkingTreeStageFiles(t *testing.T) {
|
||||
runner := oscommands.NewFakeRunner(t).
|
||||
Expect(`git add -- "test.txt" "test2.txt"`, "", nil)
|
||||
|
||||
instance := buildWorkingTreeCommands(commonDeps{runner: runner})
|
||||
|
||||
assert.NoError(t, instance.StageFiles([]string{"test.txt", "test2.txt"}))
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestWorkingTreeUnstageFile(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
|
@ -59,8 +59,8 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
|
||||
unstagedChange := change[1:2]
|
||||
untracked := utils.IncludesString([]string{"??", "A ", "AM"}, change)
|
||||
hasNoStagedChanges := utils.IncludesString([]string{" ", "U", "?"}, stagedChange)
|
||||
hasMergeConflicts := utils.IncludesString([]string{"DD", "AA", "UU", "AU", "UA", "UD", "DU"}, change)
|
||||
hasInlineMergeConflicts := utils.IncludesString([]string{"UU", "AA"}, change)
|
||||
hasMergeConflicts := hasInlineMergeConflicts || utils.IncludesString([]string{"DD", "AU", "UA", "UD", "DU"}, change)
|
||||
|
||||
file := &models.File{
|
||||
Name: status.Name,
|
||||
|
@ -167,7 +167,7 @@ func (gui *Gui) contextTree() ContextTree {
|
||||
Key: MAIN_PATCH_BUILDING_CONTEXT_KEY,
|
||||
},
|
||||
Merging: &BasicContext{
|
||||
OnFocus: OnFocusWrapper(gui.refreshMergePanelWithLock),
|
||||
OnFocus: OnFocusWrapper(gui.renderConflictsWithFocus),
|
||||
Kind: MAIN_CONTEXT,
|
||||
ViewName: "main",
|
||||
Key: MAIN_MERGING_CONTEXT_KEY,
|
||||
|
@ -8,8 +8,10 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/loaders"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
@ -54,9 +56,19 @@ func (gui *Gui) filesRenderToMain() error {
|
||||
}
|
||||
|
||||
if node.File != nil && node.File.HasInlineMergeConflicts {
|
||||
return gui.renderConflictsFromFilesPanel()
|
||||
hasConflicts, err := gui.setMergeState(node.File.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if we don't have conflicts we'll fall through and show the diff
|
||||
if hasConflicts {
|
||||
return gui.renderConflicts(false)
|
||||
}
|
||||
}
|
||||
|
||||
gui.resetMergeState()
|
||||
|
||||
cmdObj := gui.Git.WorkingTree.WorktreeFileDiffCmdObj(node, false, !node.GetHasUnstagedChanges() && node.GetHasStagedChanges(), gui.State.IgnoreWhitespaceInDiffView)
|
||||
|
||||
refreshOpts := refreshMainOpts{main: &viewUpdateOpts{
|
||||
@ -88,11 +100,16 @@ func (gui *Gui) refreshFilesAndSubmodules() error {
|
||||
gui.Mutexes.RefreshingFilesMutex.Unlock()
|
||||
}()
|
||||
|
||||
selectedPath := gui.getSelectedPath()
|
||||
prevSelectedPath := gui.getSelectedPath()
|
||||
|
||||
if err := gui.refreshStateSubmoduleConfigs(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := gui.refreshMergeState(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := gui.refreshStateFiles(); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -109,9 +126,9 @@ func (gui *Gui) refreshFilesAndSubmodules() error {
|
||||
}
|
||||
}
|
||||
|
||||
if gui.currentContext().GetKey() == FILES_CONTEXT_KEY || (gui.g.CurrentView() == gui.Views.Main && ContextKey(gui.g.CurrentView().Context) == MAIN_MERGING_CONTEXT_KEY) {
|
||||
newSelectedPath := gui.getSelectedPath()
|
||||
alreadySelected := selectedPath != "" && newSelectedPath == selectedPath
|
||||
if gui.currentContext().GetKey() == FILES_CONTEXT_KEY {
|
||||
currentSelectedPath := gui.getSelectedPath()
|
||||
alreadySelected := prevSelectedPath != "" && currentSelectedPath == prevSelectedPath
|
||||
if !alreadySelected {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
}
|
||||
@ -126,6 +143,28 @@ func (gui *Gui) refreshFilesAndSubmodules() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) refreshMergeState() error {
|
||||
gui.State.Panels.Merging.Lock()
|
||||
defer gui.State.Panels.Merging.Unlock()
|
||||
|
||||
if gui.currentContext().GetKey() != MAIN_MERGING_CONTEXT_KEY {
|
||||
return nil
|
||||
}
|
||||
|
||||
hasConflicts, err := gui.setMergeState(gui.State.Panels.Merging.GetPath())
|
||||
if err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
if hasConflicts {
|
||||
_ = gui.renderConflicts(true)
|
||||
} else {
|
||||
_ = gui.escapeMerge()
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// specific functions
|
||||
|
||||
func (gui *Gui) stagedFiles() []*models.File {
|
||||
@ -150,15 +189,6 @@ func (gui *Gui) trackedFiles() []*models.File {
|
||||
return result
|
||||
}
|
||||
|
||||
func (gui *Gui) stageSelectedFile() error {
|
||||
file := gui.getSelectedFile()
|
||||
if file == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return gui.Git.WorkingTree.StageFile(file.Name)
|
||||
}
|
||||
|
||||
func (gui *Gui) handleEnterFile() error {
|
||||
return gui.enterFile(OnFocusOpts{ClickedViewName: "", ClickedViewLineIdx: -1})
|
||||
}
|
||||
@ -558,9 +588,50 @@ func (gui *Gui) refreshStateFiles() error {
|
||||
prevNodes := gui.State.FileTreeViewModel.GetAllItems()
|
||||
prevSelectedLineIdx := gui.State.Panels.Files.SelectedLineIdx
|
||||
|
||||
// If git thinks any of our files have inline merge conflicts, but they actually don't,
|
||||
// we stage them.
|
||||
// Note that if files with merge conflicts have both arisen and have been resolved
|
||||
// between refreshes, we won't stage them here. This is super unlikely though,
|
||||
// and this approach spares us from having to call `git status` twice in a row.
|
||||
// Although this also means that at startup we won't be staging anything until
|
||||
// we call git status again.
|
||||
pathsToStage := []string{}
|
||||
prevConflictFileCount := 0
|
||||
for _, file := range state.FileTreeViewModel.GetAllFiles() {
|
||||
if file.HasMergeConflicts {
|
||||
prevConflictFileCount++
|
||||
}
|
||||
if file.HasInlineMergeConflicts {
|
||||
hasConflicts, err := mergeconflicts.FileHasConflictMarkers(file.Name)
|
||||
if err != nil {
|
||||
gui.Log.Error(err)
|
||||
} else if !hasConflicts {
|
||||
pathsToStage = append(pathsToStage, file.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(pathsToStage) > 0 {
|
||||
gui.logAction(gui.Tr.Actions.StageResolvedFiles)
|
||||
if err := gui.Git.WorkingTree.StageFiles(pathsToStage); err != nil {
|
||||
return gui.surfaceError(err)
|
||||
}
|
||||
}
|
||||
|
||||
files := gui.Git.Loaders.Files.
|
||||
GetStatusFiles(loaders.GetStatusFileOptions{})
|
||||
|
||||
conflictFileCount := 0
|
||||
for _, file := range files {
|
||||
if file.HasMergeConflicts {
|
||||
conflictFileCount++
|
||||
}
|
||||
}
|
||||
|
||||
if gui.Git.Status.WorkingTreeState() != enums.REBASE_MODE_NONE && conflictFileCount == 0 && prevConflictFileCount > 0 {
|
||||
gui.OnUIThread(func() error { return gui.promptToContinueRebase() })
|
||||
}
|
||||
|
||||
// for when you stage the old file of a rename and the new file is in a collapsed dir
|
||||
state.FileTreeViewModel.RWMutex.Lock()
|
||||
for _, file := range files {
|
||||
@ -602,6 +673,19 @@ func (gui *Gui) refreshStateFiles() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// promptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress
|
||||
func (gui *Gui) promptToContinueRebase() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
return gui.ask(askOpts{
|
||||
title: "continue",
|
||||
prompt: gui.Tr.ConflictsResolved,
|
||||
handleConfirm: func() error {
|
||||
return gui.genericMergeCommand(REBASE_OPTION_CONTINUE)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Let's try to find our file again and move the cursor to that.
|
||||
// If we can't find our file, it was probably just removed by the user. In that
|
||||
// case, we go looking for where the next file has been moved to. Given that the
|
||||
@ -859,6 +943,18 @@ func (gui *Gui) switchToMerge() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
if gui.State.Panels.Merging.GetPath() != file.Name {
|
||||
hasConflicts, err := gui.setMergeState(file.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasConflicts {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return gui.pushContext(gui.State.Contexts.Merging)
|
||||
}
|
||||
|
||||
@ -870,15 +966,6 @@ func (gui *Gui) openFile(filename string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) anyFilesWithMergeConflicts() bool {
|
||||
for _, file := range gui.State.FileTreeViewModel.GetAllFiles() {
|
||||
if file.HasMergeConflicts {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (gui *Gui) handleCustomCommand() error {
|
||||
return gui.prompt(promptOpts{
|
||||
title: gui.Tr.CustomCommand,
|
||||
|
@ -132,3 +132,32 @@ func TestCompress(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFile(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
viewModel *FileTreeViewModel
|
||||
path string
|
||||
expected *models.File
|
||||
}{
|
||||
{
|
||||
name: "valid case",
|
||||
viewModel: NewFileTreeViewModel([]*models.File{{Name: "blah/one"}, {Name: "blah/two"}}, nil, false),
|
||||
path: "blah/two",
|
||||
expected: &models.File{Name: "blah/two"},
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
viewModel: NewFileTreeViewModel([]*models.File{{Name: "blah/one"}, {Name: "blah/two"}}, nil, false),
|
||||
path: "blah/three",
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.EqualValues(t, s.expected, s.viewModel.GetFile(s.path))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -86,6 +86,16 @@ func (self *FileTreeViewModel) GetItemAtIndex(index int) *FileNode {
|
||||
return self.tree.GetNodeAtIndex(index+1, self.collapsedPaths) // ignoring root
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetFile(path string) *models.File {
|
||||
for _, file := range self.files {
|
||||
if file.Name == path {
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *FileTreeViewModel) GetIndexForPath(path string) (int, bool) {
|
||||
index, found := self.tree.GetIndexForPath(path, self.collapsedPaths)
|
||||
return index - 1, found
|
||||
|
@ -115,7 +115,7 @@ func (gui *Gui) linesToScrollDown(view *gocui.View) int {
|
||||
}
|
||||
|
||||
func (gui *Gui) scrollUpMain() error {
|
||||
if gui.canScrollMergePanel() {
|
||||
if gui.renderingConflicts() {
|
||||
gui.State.Panels.Merging.UserVerticalScrolling = true
|
||||
}
|
||||
|
||||
@ -123,7 +123,7 @@ func (gui *Gui) scrollUpMain() error {
|
||||
}
|
||||
|
||||
func (gui *Gui) scrollDownMain() error {
|
||||
if gui.canScrollMergePanel() {
|
||||
if gui.renderingConflicts() {
|
||||
gui.State.Panels.Merging.UserVerticalScrolling = true
|
||||
}
|
||||
|
||||
|
@ -292,10 +292,11 @@ type guiState struct {
|
||||
// managers for them which handle rendering a flat list of files in tree form
|
||||
FileTreeViewModel *filetree.FileTreeViewModel
|
||||
CommitFileTreeViewModel *filetree.CommitFileTreeViewModel
|
||||
Submodules []*models.SubmoduleConfig
|
||||
Branches []*models.Branch
|
||||
Commits []*models.Commit
|
||||
StashEntries []*models.StashEntry
|
||||
|
||||
Submodules []*models.SubmoduleConfig
|
||||
Branches []*models.Branch
|
||||
Commits []*models.Commit
|
||||
StashEntries []*models.StashEntry
|
||||
// Suggestions will sometimes appear when typing into a prompt
|
||||
Suggestions []*types.Suggestion
|
||||
// FilteredReflogCommits are the ones that appear in the reflog panel.
|
||||
@ -304,13 +305,14 @@ type guiState struct {
|
||||
// ReflogCommits are the ones used by the branches panel to obtain recency values
|
||||
// if we're not in filtering mode, CommitFiles and FilteredReflogCommits will be
|
||||
// one and the same
|
||||
ReflogCommits []*models.Commit
|
||||
SubCommits []*models.Commit
|
||||
Remotes []*models.Remote
|
||||
RemoteBranches []*models.RemoteBranch
|
||||
Tags []*models.Tag
|
||||
MenuItems []*menuItem
|
||||
BisectInfo *git_commands.BisectInfo
|
||||
ReflogCommits []*models.Commit
|
||||
SubCommits []*models.Commit
|
||||
Remotes []*models.Remote
|
||||
RemoteBranches []*models.RemoteBranch
|
||||
Tags []*models.Tag
|
||||
MenuItems []*menuItem
|
||||
BisectInfo *git_commands.BisectInfo
|
||||
|
||||
Updating bool
|
||||
Panels *panelStates
|
||||
SplitMainPanel bool
|
||||
|
@ -1540,20 +1540,6 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
|
||||
Handler: gui.handleSelectNextConflictHunk,
|
||||
Description: gui.Tr.SelectNextHunk,
|
||||
},
|
||||
{
|
||||
ViewName: "main",
|
||||
Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
|
||||
Key: gocui.MouseWheelUp,
|
||||
Modifier: gocui.ModNone,
|
||||
Handler: gui.handleSelectPrevConflictHunk,
|
||||
},
|
||||
{
|
||||
ViewName: "main",
|
||||
Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
|
||||
Key: gocui.MouseWheelDown,
|
||||
Modifier: gocui.ModNone,
|
||||
Handler: gui.handleSelectNextConflictHunk,
|
||||
},
|
||||
{
|
||||
ViewName: "main",
|
||||
Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
|
||||
@ -1586,7 +1572,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
|
||||
ViewName: "main",
|
||||
Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
|
||||
Key: gui.getKey(config.Universal.Undo),
|
||||
Handler: gui.handlePopFileSnapshot,
|
||||
Handler: gui.handleMergeConflictUndo,
|
||||
Description: gui.Tr.LcUndo,
|
||||
},
|
||||
{
|
||||
|
@ -7,9 +7,7 @@ import (
|
||||
"io/ioutil"
|
||||
"math"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
|
||||
)
|
||||
|
||||
@ -17,7 +15,7 @@ func (gui *Gui) handleSelectPrevConflictHunk() error {
|
||||
return gui.withMergeConflictLock(func() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
gui.State.Panels.Merging.SelectPrevConflictHunk()
|
||||
return gui.refreshMergePanel()
|
||||
return gui.renderConflictsWithFocus()
|
||||
})
|
||||
}
|
||||
|
||||
@ -25,7 +23,7 @@ func (gui *Gui) handleSelectNextConflictHunk() error {
|
||||
return gui.withMergeConflictLock(func() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
gui.State.Panels.Merging.SelectNextConflictHunk()
|
||||
return gui.refreshMergePanel()
|
||||
return gui.renderConflictsWithFocus()
|
||||
})
|
||||
}
|
||||
|
||||
@ -33,7 +31,7 @@ func (gui *Gui) handleSelectNextConflict() error {
|
||||
return gui.withMergeConflictLock(func() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
gui.State.Panels.Merging.SelectNextConflict()
|
||||
return gui.refreshMergePanel()
|
||||
return gui.renderConflictsWithFocus()
|
||||
})
|
||||
}
|
||||
|
||||
@ -41,42 +39,29 @@ func (gui *Gui) handleSelectPrevConflict() error {
|
||||
return gui.withMergeConflictLock(func() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
gui.State.Panels.Merging.SelectPrevConflict()
|
||||
return gui.refreshMergePanel()
|
||||
return gui.renderConflictsWithFocus()
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) pushFileSnapshot() error {
|
||||
content, err := gui.catSelectedFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gui.State.Panels.Merging.PushFileSnapshot(content)
|
||||
return nil
|
||||
}
|
||||
func (gui *Gui) handleMergeConflictUndo() error {
|
||||
state := gui.State.Panels.Merging
|
||||
|
||||
func (gui *Gui) handlePopFileSnapshot() error {
|
||||
prevContent, ok := gui.State.Panels.Merging.PopFileSnapshot()
|
||||
ok := state.Undo()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
gitFile := gui.getSelectedFile()
|
||||
if gitFile == nil {
|
||||
return nil
|
||||
}
|
||||
gui.logAction("Restoring file to previous state")
|
||||
gui.logCommand("Undoing last conflict resolution", false)
|
||||
if err := ioutil.WriteFile(gitFile.Name, []byte(prevContent), 0644); err != nil {
|
||||
if err := ioutil.WriteFile(state.GetPath(), []byte(state.GetContent()), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return gui.refreshMergePanel()
|
||||
return gui.renderConflictsWithFocus()
|
||||
}
|
||||
|
||||
func (gui *Gui) handlePickHunk() error {
|
||||
return gui.withMergeConflictLock(func() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
ok, err := gui.resolveConflict(gui.State.Panels.Merging.Selection())
|
||||
if err != nil {
|
||||
return err
|
||||
@ -86,20 +71,16 @@ func (gui *Gui) handlePickHunk() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if gui.State.Panels.Merging.IsFinalConflict() {
|
||||
if err := gui.handleCompleteMerge(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
if gui.State.Panels.Merging.AllConflictsResolved() {
|
||||
return gui.onLastConflictResolved()
|
||||
}
|
||||
return gui.refreshMergePanel()
|
||||
|
||||
return gui.renderConflictsWithFocus()
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) handlePickAllHunks() error {
|
||||
return gui.withMergeConflictLock(func() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
ok, err := gui.resolveConflict(mergeconflicts.ALL)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -109,17 +90,20 @@ func (gui *Gui) handlePickAllHunks() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return gui.refreshMergePanel()
|
||||
if gui.State.Panels.Merging.AllConflictsResolved() {
|
||||
return gui.onLastConflictResolved()
|
||||
}
|
||||
|
||||
return gui.renderConflictsWithFocus()
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) resolveConflict(selection mergeconflicts.Selection) (bool, error) {
|
||||
gitFile := gui.getSelectedFile()
|
||||
if gitFile == nil {
|
||||
return false, nil
|
||||
}
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
ok, output, err := gui.State.Panels.Merging.ContentAfterConflictResolve(gitFile.Name, selection)
|
||||
state := gui.State.Panels.Merging
|
||||
|
||||
ok, content, err := state.ContentAfterConflictResolve(selection)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@ -128,10 +112,6 @@ func (gui *Gui) resolveConflict(selection mergeconflicts.Selection) (bool, error
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := gui.pushFileSnapshot(); err != nil {
|
||||
return false, gui.surfaceError(err)
|
||||
}
|
||||
|
||||
var logStr string
|
||||
switch selection {
|
||||
case mergeconflicts.TOP:
|
||||
@ -145,40 +125,14 @@ func (gui *Gui) resolveConflict(selection mergeconflicts.Selection) (bool, error
|
||||
}
|
||||
gui.logAction("Resolve merge conflict")
|
||||
gui.logCommand(logStr, false)
|
||||
return true, ioutil.WriteFile(gitFile.Name, []byte(output), 0644)
|
||||
state.PushContent(content)
|
||||
return true, ioutil.WriteFile(state.GetPath(), []byte(content), 0644)
|
||||
}
|
||||
|
||||
func (gui *Gui) refreshMergePanelWithLock() error {
|
||||
return gui.withMergeConflictLock(gui.refreshMergePanel)
|
||||
}
|
||||
|
||||
// not re-using state here because we can run into issues with mutexes when
|
||||
// doing that.
|
||||
func (gui *Gui) renderConflictsFromFilesPanel() error {
|
||||
state := mergeconflicts.NewState()
|
||||
_, err := gui.renderConflicts(state, false)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (gui *Gui) renderConflicts(state *mergeconflicts.State, hasFocus bool) (bool, error) {
|
||||
cat, err := gui.catSelectedFile()
|
||||
if err != nil {
|
||||
return false, gui.refreshMainViews(refreshMainOpts{
|
||||
main: &viewUpdateOpts{
|
||||
title: "",
|
||||
task: NewRenderStringTask(err.Error()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
state.SetConflictsFromCat(cat)
|
||||
|
||||
if state.NoConflicts() {
|
||||
return false, gui.handleCompleteMerge()
|
||||
}
|
||||
|
||||
content := mergeconflicts.ColoredConflictFile(cat, state, hasFocus)
|
||||
// precondition: we actually have conflicts to render
|
||||
func (gui *Gui) renderConflicts(hasFocus bool) error {
|
||||
state := gui.State.Panels.Merging.State
|
||||
content := mergeconflicts.ColoredConflictFile(state, hasFocus)
|
||||
|
||||
if !gui.State.Panels.Merging.UserVerticalScrolling {
|
||||
// TODO: find a way to not have to do this OnUIThread thing. Why doesn't it work
|
||||
@ -189,7 +143,7 @@ func (gui *Gui) renderConflicts(state *mergeconflicts.State, hasFocus bool) (boo
|
||||
})
|
||||
}
|
||||
|
||||
return true, gui.refreshMainViews(refreshMainOpts{
|
||||
return gui.refreshMainViews(refreshMainOpts{
|
||||
main: &viewUpdateOpts{
|
||||
title: gui.Tr.MergeConflictsTitle,
|
||||
task: NewRenderStringWithoutScrollTask(content),
|
||||
@ -198,35 +152,8 @@ func (gui *Gui) renderConflicts(state *mergeconflicts.State, hasFocus bool) (boo
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) refreshMergePanel() error {
|
||||
conflictsFound, err := gui.renderConflicts(gui.State.Panels.Merging.State, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !conflictsFound {
|
||||
return gui.handleCompleteMerge()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) catSelectedFile() (string, error) {
|
||||
item := gui.getSelectedFile()
|
||||
if item == nil {
|
||||
return "", errors.New(gui.Tr.NoFilesDisplay)
|
||||
}
|
||||
|
||||
if item.Type != "file" {
|
||||
return "", errors.New(gui.Tr.NotAFile)
|
||||
}
|
||||
|
||||
cat, err := gui.Git.File.Cat(item.Name)
|
||||
if err != nil {
|
||||
gui.Log.Error(err)
|
||||
return "", err
|
||||
}
|
||||
return cat, nil
|
||||
func (gui *Gui) renderConflictsWithFocus() error {
|
||||
return gui.renderConflicts(true)
|
||||
}
|
||||
|
||||
func (gui *Gui) centerYPos(view *gocui.View, y int) {
|
||||
@ -256,68 +183,47 @@ func (gui *Gui) handleEscapeMerge() error {
|
||||
return gui.escapeMerge()
|
||||
}
|
||||
|
||||
func (gui *Gui) handleCompleteMerge() error {
|
||||
if err := gui.stageSelectedFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}}); err != nil {
|
||||
return err
|
||||
func (gui *Gui) onLastConflictResolved() error {
|
||||
// as part of refreshing files, we handle the situation where a file has had
|
||||
// its merge conflicts resolved.
|
||||
return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
|
||||
}
|
||||
|
||||
func (gui *Gui) resetMergeState() {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
gui.State.Panels.Merging.Reset()
|
||||
}
|
||||
|
||||
func (gui *Gui) setMergeState(path string) (bool, error) {
|
||||
content, err := gui.Git.File.Cat(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// if there are no more files with merge conflicts, we should ask whether the user wants to continue
|
||||
if gui.Git.Status.WorkingTreeState() != enums.REBASE_MODE_NONE && !gui.anyFilesWithMergeConflicts() {
|
||||
return gui.promptToContinueRebase()
|
||||
}
|
||||
gui.State.Panels.Merging.SetContent(content, path)
|
||||
|
||||
return gui.escapeMerge()
|
||||
return !gui.State.Panels.Merging.NoConflicts(), nil
|
||||
}
|
||||
|
||||
func (gui *Gui) escapeMerge() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
gui.State.Panels.Merging.Reset()
|
||||
gui.resetMergeState()
|
||||
|
||||
// it's possible this method won't be called from the merging view so we need to
|
||||
// ensure we only 'return' focus if we already have it
|
||||
if gui.g.CurrentView() == gui.Views.Main {
|
||||
|
||||
if gui.currentContext().GetKey() == MAIN_MERGING_CONTEXT_KEY {
|
||||
return gui.pushContext(gui.State.Contexts.Files)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// promptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress
|
||||
func (gui *Gui) promptToContinueRebase() error {
|
||||
gui.takeOverMergeConflictScrolling()
|
||||
|
||||
return gui.ask(askOpts{
|
||||
title: "continue",
|
||||
prompt: gui.Tr.ConflictsResolved,
|
||||
handlersManageFocus: true,
|
||||
handleConfirm: func() error {
|
||||
if err := gui.pushContext(gui.State.Contexts.Files); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return gui.genericMergeCommand(REBASE_OPTION_CONTINUE)
|
||||
},
|
||||
handleClose: func() error {
|
||||
return gui.pushContext(gui.State.Contexts.Files)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) canScrollMergePanel() bool {
|
||||
func (gui *Gui) renderingConflicts() bool {
|
||||
currentView := gui.g.CurrentView()
|
||||
if currentView != gui.Views.Main && currentView != gui.Views.Files {
|
||||
return false
|
||||
}
|
||||
|
||||
file := gui.getSelectedFile()
|
||||
if file == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return file.HasInlineMergeConflicts
|
||||
return gui.State.Panels.Merging.Active()
|
||||
}
|
||||
|
||||
func (gui *Gui) withMergeConflictLock(f func() error) error {
|
||||
|
@ -1,6 +1,10 @@
|
||||
package mergeconflicts
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
@ -53,19 +57,59 @@ func findConflicts(content string) []*mergeConflict {
|
||||
return conflicts
|
||||
}
|
||||
|
||||
var CONFLICT_START = "<<<<<<< "
|
||||
var CONFLICT_END = ">>>>>>> "
|
||||
var CONFLICT_START_BYTES = []byte(CONFLICT_START)
|
||||
var CONFLICT_END_BYTES = []byte(CONFLICT_END)
|
||||
|
||||
func determineLineType(line string) LineType {
|
||||
// TODO: find out whether we ever actually get this prefix
|
||||
trimmedLine := strings.TrimPrefix(line, "++")
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(trimmedLine, "<<<<<<< "):
|
||||
case strings.HasPrefix(trimmedLine, CONFLICT_START):
|
||||
return START
|
||||
case strings.HasPrefix(trimmedLine, "||||||| "):
|
||||
return ANCESTOR
|
||||
case trimmedLine == "=======":
|
||||
return TARGET
|
||||
case strings.HasPrefix(trimmedLine, ">>>>>>> "):
|
||||
case strings.HasPrefix(trimmedLine, CONFLICT_END):
|
||||
return END
|
||||
default:
|
||||
return NOT_A_MARKER
|
||||
}
|
||||
}
|
||||
|
||||
// tells us whether a file actually has inline merge conflicts. We need to run this
|
||||
// because git will continue showing a status of 'UU' even after the conflicts have
|
||||
// been resolved in the user's editor
|
||||
func FileHasConflictMarkers(path string) (bool, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
return fileHasConflictMarkersAux(file), nil
|
||||
}
|
||||
|
||||
// Efficiently scans through a file looking for merge conflict markers. Returns true if it does
|
||||
func fileHasConflictMarkersAux(file io.Reader) bool {
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
// only searching for start/end markers because the others are more ambiguous
|
||||
if bytes.HasPrefix(line, CONFLICT_START_BYTES) {
|
||||
return true
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(line, CONFLICT_END_BYTES) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package mergeconflicts
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@ -59,3 +60,42 @@ func TestDetermineLineType(t *testing.T) {
|
||||
assert.EqualValues(t, s.expected, determineLineType(s.line))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConflictsAux(t *testing.T) {
|
||||
type scenario struct {
|
||||
content string
|
||||
expected bool
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
content: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
content: "blah",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
content: ">>>>>>> ",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
content: "<<<<<<< ",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
content: " <<<<<<< ",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
content: "a\nb\nc\n<<<<<<< ",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
reader := strings.NewReader(s.content)
|
||||
assert.EqualValues(t, s.expected, fileHasConflictMarkersAux(reader))
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,8 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
func ColoredConflictFile(content string, state *State, hasFocus bool) string {
|
||||
func ColoredConflictFile(state *State, hasFocus bool) string {
|
||||
content := state.GetContent()
|
||||
if len(state.conflicts) == 0 {
|
||||
return content
|
||||
}
|
||||
|
@ -3,13 +3,19 @@ package mergeconflicts
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/golang-collections/collections/stack"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
sync.Mutex
|
||||
|
||||
// path of the file with the conflicts
|
||||
path string
|
||||
|
||||
// This is a stack of the file content. It is used to undo changes.
|
||||
// The last item is the current file content.
|
||||
contents []string
|
||||
|
||||
conflicts []*mergeConflict
|
||||
// this is the index of the above `conflicts` field which is currently selected
|
||||
conflictIndex int
|
||||
@ -17,9 +23,6 @@ type State struct {
|
||||
// this is the index of the selected conflict's available selections slice e.g. [TOP, MIDDLE, BOTTOM]
|
||||
// We use this to know which hunk of the conflict is selected.
|
||||
selectionIndex int
|
||||
|
||||
// this allows us to undo actions
|
||||
EditHistory *stack.Stack
|
||||
}
|
||||
|
||||
func NewState() *State {
|
||||
@ -28,7 +31,7 @@ func NewState() *State {
|
||||
conflictIndex: 0,
|
||||
selectionIndex: 0,
|
||||
conflicts: []*mergeConflict{},
|
||||
EditHistory: stack.New(),
|
||||
contents: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,18 +66,6 @@ func (s *State) SelectPrevConflict() {
|
||||
s.setConflictIndex(s.conflictIndex - 1)
|
||||
}
|
||||
|
||||
func (s *State) PushFileSnapshot(content string) {
|
||||
s.EditHistory.Push(content)
|
||||
}
|
||||
|
||||
func (s *State) PopFileSnapshot() (string, bool) {
|
||||
if s.EditHistory.Len() == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return s.EditHistory.Pop().(string), true
|
||||
}
|
||||
|
||||
func (s *State) currentConflict() *mergeConflict {
|
||||
if len(s.conflicts) == 0 {
|
||||
return nil
|
||||
@ -83,8 +74,48 @@ func (s *State) currentConflict() *mergeConflict {
|
||||
return s.conflicts[s.conflictIndex]
|
||||
}
|
||||
|
||||
func (s *State) SetConflictsFromCat(cat string) {
|
||||
s.setConflicts(findConflicts(cat))
|
||||
// this is for starting a new merge conflict session
|
||||
func (s *State) SetContent(content string, path string) {
|
||||
if content == s.GetContent() && path == s.path {
|
||||
return
|
||||
}
|
||||
|
||||
s.path = path
|
||||
s.contents = []string{}
|
||||
s.PushContent(content)
|
||||
}
|
||||
|
||||
// this is for when you've resolved a conflict. This allows you to undo to a previous
|
||||
// state
|
||||
func (s *State) PushContent(content string) {
|
||||
s.contents = append(s.contents, content)
|
||||
s.setConflicts(findConflicts(content))
|
||||
}
|
||||
|
||||
func (s *State) GetContent() string {
|
||||
if len(s.contents) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s.contents[len(s.contents)-1]
|
||||
}
|
||||
|
||||
func (s *State) GetPath() string {
|
||||
return s.path
|
||||
}
|
||||
|
||||
func (s *State) Undo() bool {
|
||||
if len(s.contents) <= 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
s.contents = s.contents[:len(s.contents)-1]
|
||||
|
||||
newContent := s.GetContent()
|
||||
// We could be storing the old conflicts and selected index on a stack too.
|
||||
s.setConflicts(findConflicts(newContent))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *State) setConflicts(conflicts []*mergeConflict) {
|
||||
@ -110,29 +141,31 @@ func (s *State) availableSelections() []Selection {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) IsFinalConflict() bool {
|
||||
return len(s.conflicts) == 1
|
||||
func (s *State) AllConflictsResolved() bool {
|
||||
return len(s.conflicts) == 0
|
||||
}
|
||||
|
||||
func (s *State) Reset() {
|
||||
s.EditHistory = stack.New()
|
||||
s.contents = []string{}
|
||||
s.path = ""
|
||||
}
|
||||
|
||||
func (s *State) Active() bool {
|
||||
return s.path != ""
|
||||
}
|
||||
|
||||
func (s *State) GetConflictMiddle() int {
|
||||
return s.currentConflict().target
|
||||
}
|
||||
|
||||
func (s *State) ContentAfterConflictResolve(
|
||||
path string,
|
||||
selection Selection,
|
||||
) (bool, string, error) {
|
||||
func (s *State) ContentAfterConflictResolve(selection Selection) (bool, string, error) {
|
||||
conflict := s.currentConflict()
|
||||
if conflict == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
content := ""
|
||||
err := utils.ForEachLineInFile(path, func(line string, i int) {
|
||||
err := utils.ForEachLineInFile(s.path, func(line string, i int) {
|
||||
if selection.isIndexToKeep(conflict, i) {
|
||||
content += line
|
||||
}
|
||||
|
@ -507,6 +507,7 @@ type Actions struct {
|
||||
DiscardAllChangesInFile string
|
||||
DiscardAllUnstagedChangesInFile string
|
||||
StageFile string
|
||||
StageResolvedFiles string
|
||||
UnstageFile string
|
||||
UnstageAllFiles string
|
||||
StageAllFiles string
|
||||
@ -1064,6 +1065,7 @@ func EnglishTranslationSet() TranslationSet {
|
||||
DiscardAllChangesInFile: "Discard all changes in file",
|
||||
DiscardAllUnstagedChangesInFile: "Discard all unstaged changes in file",
|
||||
StageFile: "Stage file",
|
||||
StageResolvedFiles: "Stage files whose merge conflicts were resolved",
|
||||
UnstageFile: "Unstage file",
|
||||
UnstageAllFiles: "Unstage all files",
|
||||
StageAllFiles: "Stage all files",
|
||||
|
5
test/integration/mergeConflicts/config/config.yml
Normal file
5
test/integration/mergeConflicts/config/config.yml
Normal file
@ -0,0 +1,5 @@
|
||||
disableStartupPopups: true
|
||||
gui:
|
||||
showFileTree: false
|
||||
refresher:
|
||||
refreshInterval: 1
|
Loading…
x
Reference in New Issue
Block a user