mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-07-03 00:57:52 +02:00
Refresh is one of those functions that shouldn't require error handling (similar to triggering a redraw of the UI, see https://github.com/jesseduffield/lazygit/issues/3887). As far as I see, the only reason why Refresh can currently return an error is that the Then function returns one. The actual refresh errors, e.g. from the git calls that are made to fetch data, are already logged and swallowed. Most of the Then functions do only UI stuff such as selecting a list item, and always return nil; there's only one that can return an error (updating the rebase todo file in LocalCommitsController.startInteractiveRebaseWithEdit); it's not a critical error if this fails, it is only used for setting rebase todo items to "edit" when you start an interactive rebase by pressing 'e' on a range selection of commits. We simply log this error instead of returning it.
467 lines
14 KiB
Go
467 lines
14 KiB
Go
package helpers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/jesseduffield/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/samber/lo"
|
|
"github.com/stefanhaller/git-todo-parser/todo"
|
|
)
|
|
|
|
type MergeAndRebaseHelper struct {
|
|
c *HelperCommon
|
|
}
|
|
|
|
func NewMergeAndRebaseHelper(
|
|
c *HelperCommon,
|
|
) *MergeAndRebaseHelper {
|
|
return &MergeAndRebaseHelper{
|
|
c: c,
|
|
}
|
|
}
|
|
|
|
type RebaseOption string
|
|
|
|
const (
|
|
REBASE_OPTION_CONTINUE string = "continue"
|
|
REBASE_OPTION_ABORT string = "abort"
|
|
REBASE_OPTION_SKIP string = "skip"
|
|
)
|
|
|
|
func (self *MergeAndRebaseHelper) CreateRebaseOptionsMenu() error {
|
|
type optionAndKey struct {
|
|
option string
|
|
key types.Key
|
|
}
|
|
|
|
options := []optionAndKey{
|
|
{option: REBASE_OPTION_CONTINUE, key: 'c'},
|
|
{option: REBASE_OPTION_ABORT, key: 'a'},
|
|
}
|
|
|
|
if self.c.Git().Status.WorkingTreeState().CanSkip() {
|
|
options = append(options, optionAndKey{
|
|
option: REBASE_OPTION_SKIP, key: 's',
|
|
})
|
|
}
|
|
|
|
menuItems := lo.Map(options, func(row optionAndKey, _ int) *types.MenuItem {
|
|
return &types.MenuItem{
|
|
Label: row.option,
|
|
OnPress: func() error {
|
|
return self.genericMergeCommand(row.option)
|
|
},
|
|
Key: row.key,
|
|
}
|
|
})
|
|
|
|
title := self.c.Git().Status.WorkingTreeState().OptionsMenuTitle(self.c.Tr)
|
|
return self.c.Menu(types.CreateMenuOptions{Title: title, Items: menuItems})
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) ContinueRebase() error {
|
|
return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error {
|
|
status := self.c.Git().Status.WorkingTreeState()
|
|
|
|
if status.None() {
|
|
return errors.New(self.c.Tr.NotMergingOrRebasing)
|
|
}
|
|
|
|
self.c.LogAction(fmt.Sprintf("Merge/Rebase: %s", command))
|
|
effectiveStatus := status.Effective()
|
|
if effectiveStatus == models.WORKING_TREE_STATE_REBASING {
|
|
todoFile, err := os.ReadFile(
|
|
filepath.Join(self.c.Git().RepoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo"),
|
|
)
|
|
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
} else {
|
|
self.c.LogCommand(string(todoFile), false)
|
|
}
|
|
}
|
|
|
|
commandType := status.CommandName()
|
|
|
|
// we should end up with a command like 'git merge --continue'
|
|
|
|
// it's impossible for a rebase to require a commit so we'll use a subprocess only if it's a merge
|
|
needsSubprocess := (effectiveStatus == models.WORKING_TREE_STATE_MERGING && command != REBASE_OPTION_ABORT && self.c.UserConfig().Git.Merging.ManualCommit) ||
|
|
// but we'll also use a subprocess if we have exec todos; those are likely to be lengthy build
|
|
// tasks whose output the user will want to see in the terminal
|
|
(effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos())
|
|
|
|
if needsSubprocess {
|
|
// TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction
|
|
return self.c.RunSubprocessAndRefresh(
|
|
self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command),
|
|
)
|
|
}
|
|
result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command)
|
|
if err := self.CheckMergeOrRebase(result); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) hasExecTodos() bool {
|
|
for _, commit := range self.c.Model().Commits {
|
|
if !commit.IsTODO() {
|
|
break
|
|
}
|
|
if commit.Action == todo.Exec {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var conflictStrings = []string{
|
|
"Failed to merge in the changes",
|
|
"When you have resolved this problem",
|
|
"fix conflicts",
|
|
"Resolve all conflicts manually",
|
|
"Merge conflict in file",
|
|
"hint: after resolving the conflicts",
|
|
"CONFLICT (content):",
|
|
}
|
|
|
|
func isMergeConflictErr(errStr string) bool {
|
|
for _, str := range conflictStrings {
|
|
if strings.Contains(errStr, str) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result error, refreshOptions types.RefreshOptions) error {
|
|
self.c.Refresh(refreshOptions)
|
|
|
|
if result == nil {
|
|
return nil
|
|
} else if strings.Contains(result.Error(), "No changes - did you forget to use") {
|
|
return self.genericMergeCommand(REBASE_OPTION_SKIP)
|
|
} else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") {
|
|
return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
|
|
} else if strings.Contains(result.Error(), "No rebase in progress?") {
|
|
// assume in this case that we're already done
|
|
return nil
|
|
}
|
|
return self.CheckForConflicts(result)
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error {
|
|
return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.ASYNC})
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error {
|
|
if result == nil {
|
|
return nil
|
|
}
|
|
|
|
if isMergeConflictErr(result.Error()) {
|
|
return self.PromptForConflictHandling()
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) PromptForConflictHandling() error {
|
|
mode := self.c.Git().Status.WorkingTreeState().CommandName()
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.FoundConflictsTitle,
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: self.c.Tr.ViewConflictsMenuItem,
|
|
OnPress: func() error {
|
|
self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{})
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
Label: fmt.Sprintf(self.c.Tr.AbortMenuItem, mode),
|
|
OnPress: func() error {
|
|
return self.genericMergeCommand(REBASE_OPTION_ABORT)
|
|
},
|
|
Key: 'a',
|
|
},
|
|
},
|
|
HideCancel: true,
|
|
})
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error {
|
|
// prompt user to confirm that they want to abort, then do it
|
|
mode := self.c.Git().Status.WorkingTreeState().CommandName()
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: fmt.Sprintf(self.c.Tr.AbortTitle, mode),
|
|
Prompt: fmt.Sprintf(self.c.Tr.AbortPrompt, mode),
|
|
HandleConfirm: func() error {
|
|
return self.genericMergeCommand(REBASE_OPTION_ABORT)
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// PromptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress
|
|
func (self *MergeAndRebaseHelper) PromptToContinueRebase() error {
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: self.c.Tr.Continue,
|
|
Prompt: fmt.Sprintf(self.c.Tr.ConflictsResolved, self.c.Git().Status.WorkingTreeState().CommandName()),
|
|
HandleConfirm: func() error {
|
|
// By the time we get here, we might have unstaged changes again,
|
|
// e.g. if the user had to fix build errors after resolving the
|
|
// conflicts, but after lazygit opened the prompt already. Ask again
|
|
// to auto-stage these.
|
|
|
|
// Need to refresh the files to be really sure if this is the case.
|
|
// We would otherwise be relying on lazygit's auto-refresh on focus,
|
|
// but this is not supported by all terminals or on all platforms.
|
|
self.c.Refresh(types.RefreshOptions{
|
|
Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES},
|
|
})
|
|
|
|
root := self.c.Contexts().Files.FileTreeViewModel.GetRoot()
|
|
if root.GetHasUnstagedChanges() {
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: self.c.Tr.Continue,
|
|
Prompt: self.c.Tr.UnstagedFilesAfterConflictsResolved,
|
|
HandleConfirm: func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
|
|
if err := self.c.Git().WorkingTree.StageAll(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
|
|
checkedOutBranch := self.c.Model().Branches[0]
|
|
checkedOutBranchName := checkedOutBranch.Name
|
|
var disabledReason, baseBranchDisabledReason *types.DisabledReason
|
|
if checkedOutBranchName == ref {
|
|
disabledReason = &types.DisabledReason{Text: self.c.Tr.CantRebaseOntoSelf}
|
|
}
|
|
|
|
baseBranch, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(checkedOutBranch, self.c.Model().MainBranches)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if baseBranch == "" {
|
|
baseBranch = self.c.Tr.CouldNotDetermineBaseBranch
|
|
baseBranchDisabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch}
|
|
}
|
|
|
|
menuItems := []*types.MenuItem{
|
|
{
|
|
Label: utils.ResolvePlaceholderString(self.c.Tr.SimpleRebase,
|
|
map[string]string{"ref": ref},
|
|
),
|
|
Key: 's',
|
|
DisabledReason: disabledReason,
|
|
OnPress: func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.RebaseBranch)
|
|
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error {
|
|
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
|
|
var err error
|
|
if baseCommit != "" {
|
|
err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(ref, baseCommit)
|
|
} else {
|
|
err = self.c.Git().Rebase.RebaseBranch(ref)
|
|
}
|
|
err = self.CheckMergeOrRebase(err)
|
|
if err == nil {
|
|
return self.ResetMarkedBaseCommit()
|
|
}
|
|
return err
|
|
})
|
|
},
|
|
},
|
|
{
|
|
Label: utils.ResolvePlaceholderString(self.c.Tr.InteractiveRebase,
|
|
map[string]string{"ref": ref},
|
|
),
|
|
Key: 'i',
|
|
DisabledReason: disabledReason,
|
|
Tooltip: self.c.Tr.InteractiveRebaseTooltip,
|
|
OnPress: func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.RebaseBranch)
|
|
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
|
|
var err error
|
|
if baseCommit != "" {
|
|
err = self.c.Git().Rebase.EditRebaseFromBaseCommit(ref, baseCommit)
|
|
} else {
|
|
err = self.c.Git().Rebase.EditRebase(ref)
|
|
}
|
|
if err = self.CheckMergeOrRebase(err); err != nil {
|
|
return err
|
|
}
|
|
if err = self.ResetMarkedBaseCommit(); err != nil {
|
|
return err
|
|
}
|
|
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
Label: utils.ResolvePlaceholderString(self.c.Tr.RebaseOntoBaseBranch,
|
|
map[string]string{"baseBranch": ShortBranchName(baseBranch)},
|
|
),
|
|
Key: 'b',
|
|
DisabledReason: baseBranchDisabledReason,
|
|
Tooltip: self.c.Tr.RebaseOntoBaseBranchTooltip,
|
|
OnPress: func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.RebaseBranch)
|
|
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error {
|
|
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
|
|
var err error
|
|
if baseCommit != "" {
|
|
err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(baseBranch, baseCommit)
|
|
} else {
|
|
err = self.c.Git().Rebase.RebaseBranch(baseBranch)
|
|
}
|
|
err = self.CheckMergeOrRebase(err)
|
|
if err == nil {
|
|
return self.ResetMarkedBaseCommit()
|
|
}
|
|
return err
|
|
})
|
|
},
|
|
},
|
|
}
|
|
|
|
title := utils.ResolvePlaceholderString(
|
|
lo.Ternary(self.c.Modes().MarkedBaseCommit.GetHash() != "",
|
|
self.c.Tr.RebasingFromBaseCommitTitle,
|
|
self.c.Tr.RebasingTitle),
|
|
map[string]string{
|
|
"checkedOutBranch": checkedOutBranchName,
|
|
},
|
|
)
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: title,
|
|
Items: menuItems,
|
|
})
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) error {
|
|
if self.c.Git().Branch.IsHeadDetached() {
|
|
return errors.New("Cannot merge branch in detached head state. You might have checked out a commit directly or a remote branch, in which case you should checkout the local branch you want to be on")
|
|
}
|
|
checkedOutBranchName := self.c.Model().Branches[0].Name
|
|
if checkedOutBranchName == refName {
|
|
return errors.New(self.c.Tr.CantMergeBranchIntoItself)
|
|
}
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.Merge,
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: self.c.Tr.RegularMerge,
|
|
OnPress: self.RegularMerge(refName),
|
|
Key: 'm',
|
|
Tooltip: utils.ResolvePlaceholderString(
|
|
self.c.Tr.RegularMergeTooltip,
|
|
map[string]string{
|
|
"checkedOutBranch": checkedOutBranchName,
|
|
"selectedBranch": refName,
|
|
},
|
|
),
|
|
},
|
|
{
|
|
Label: self.c.Tr.SquashMergeUncommittedTitle,
|
|
OnPress: self.SquashMergeUncommitted(refName),
|
|
Key: 's',
|
|
Tooltip: utils.ResolvePlaceholderString(
|
|
self.c.Tr.SquashMergeUncommitted,
|
|
map[string]string{
|
|
"selectedBranch": refName,
|
|
},
|
|
),
|
|
},
|
|
{
|
|
Label: self.c.Tr.SquashMergeCommittedTitle,
|
|
OnPress: self.SquashMergeCommitted(refName, checkedOutBranchName),
|
|
Key: 'S',
|
|
Tooltip: utils.ResolvePlaceholderString(
|
|
self.c.Tr.SquashMergeCommitted,
|
|
map[string]string{
|
|
"checkedOutBranch": checkedOutBranchName,
|
|
"selectedBranch": refName,
|
|
},
|
|
),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) RegularMerge(refName string) func() error {
|
|
return func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.Merge)
|
|
err := self.c.Git().Branch.Merge(refName, git_commands.MergeOpts{})
|
|
return self.CheckMergeOrRebase(err)
|
|
}
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) SquashMergeUncommitted(refName string) func() error {
|
|
return func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.SquashMerge)
|
|
err := self.c.Git().Branch.Merge(refName, git_commands.MergeOpts{Squash: true})
|
|
return self.CheckMergeOrRebase(err)
|
|
}
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranchName string) func() error {
|
|
return func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.SquashMerge)
|
|
err := self.c.Git().Branch.Merge(refName, git_commands.MergeOpts{Squash: true})
|
|
if err = self.CheckMergeOrRebase(err); err != nil {
|
|
return err
|
|
}
|
|
message := utils.ResolvePlaceholderString(self.c.UserConfig().Git.Merging.SquashMergeMessage, map[string]string{
|
|
"selectedRef": refName,
|
|
"currentBranch": checkedOutBranchName,
|
|
})
|
|
err = self.c.Git().Commit.CommitCmdObj(message, "", false).Run()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (self *MergeAndRebaseHelper) ResetMarkedBaseCommit() error {
|
|
self.c.Modes().MarkedBaseCommit.Reset()
|
|
self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits)
|
|
return nil
|
|
}
|