mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-11 16:16:28 -04:00
Operations that check something out (checkout, create branch, move commits to a new branch, fetch-and-checkout) selected the newly checked-out branch by calling SelectFirstBranchAndFirstCommit() before the refresh and passing KeepBranchSelectionIndex so the refresh wouldn't override it. That set the selection directly, usually from a worker goroutine (WithWaitingStatus/WithInlineStatus). Now that the refresh's own selection write is bounced onto the UI thread, the two writes could land in either order, and under load the refresh's "restore the previously-selected branch" write would win — leaving the old branch selected instead of the new one (flaky move_commits_to_new_branch_from_base_branch). Replace it with declarative selection intents applied inside the refresh's own bounce, so the selection is set on the UI thread and atomically with the list write (no off-thread write, and no BLOCK_UI needed to avoid a flicker): - BranchSelection: SelectCheckedOutBranch selects the checked-out branch (top of the list). The default, KeepBranchSelectionByName, restores the previously-selected branch by name as before. This replaces the KeepBranchSelectionIndex bool. - CommitSelection: SelectHeadCommit (already existed) for the commit. - SelectTopReflogCommit selects the top reflog entry, since a checkout adds a new entry there (reflog/checkout relies on this). SelectFirstBranchAndFirstCommit is gone. The previously-selected branch is now read at the top of the branches bounce, before the list is overwritten, so that read moves onto the UI thread too. fetchAndCheckout's refresh changes from ASYNC to SYNC so its post-refresh focus switch can run in Then on the UI thread; SYNC keeps the inline fetch spinner spinning (only BLOCK_UI would freeze it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
974 lines
32 KiB
Go
974 lines
32 KiB
Go
package controllers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/gookit/color"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
type BranchesController struct {
|
|
baseController
|
|
*ListControllerTrait[*models.Branch]
|
|
c *ControllerCommon
|
|
}
|
|
|
|
var _ types.IController = &BranchesController{}
|
|
|
|
func NewBranchesController(
|
|
c *ControllerCommon,
|
|
) *BranchesController {
|
|
return &BranchesController{
|
|
baseController: baseController{},
|
|
c: c,
|
|
ListControllerTrait: NewListControllerTrait(
|
|
c,
|
|
c.Contexts().Branches,
|
|
c.Contexts().Branches.GetSelected,
|
|
c.Contexts().Branches.GetSelectedItems,
|
|
),
|
|
}
|
|
}
|
|
|
|
func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
|
return []*types.Binding{
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Select),
|
|
Handler: self.withItem(self.press),
|
|
GetDisabledReason: self.require(
|
|
self.singleItemSelected(),
|
|
self.notPulling,
|
|
),
|
|
Description: self.c.Tr.Checkout,
|
|
Tooltip: self.c.Tr.CheckoutTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.New),
|
|
Handler: self.withItem(self.newBranch),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.NewBranch,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.MoveCommitsToNewBranch),
|
|
Handler: self.c.Helpers().Refs.MoveCommitsToNewBranch,
|
|
GetDisabledReason: self.c.Helpers().Refs.CanMoveCommitsToNewBranch,
|
|
Description: self.c.Tr.MoveCommitsToNewBranch,
|
|
Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.NewWorktree),
|
|
Handler: self.withItem(self.c.Helpers().Worktree.NewWorktreeMenuForBranch),
|
|
Description: self.c.Tr.NewWorktree,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.CreatePullRequest),
|
|
Handler: self.withItem(self.handleCreatePullRequest),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.CreatePullRequest,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.ViewPullRequestOptions),
|
|
Handler: self.withItem(self.handleCreatePullRequestMenu),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.CreatePullRequestOptions,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.OpenPullRequestInBrowser),
|
|
Handler: self.withItem(self.openPRInBrowser),
|
|
GetDisabledReason: self.require(self.singleItemSelected(self.branchHasPR)),
|
|
Description: self.c.Tr.OpenPullRequestInBrowser,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.CopyPullRequestURL),
|
|
Handler: self.copyPullRequestURL,
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.CopyPullRequestURL,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.CheckoutBranchByName),
|
|
Handler: self.checkoutByName,
|
|
Description: self.c.Tr.CheckoutByName,
|
|
Tooltip: self.c.Tr.CheckoutByNameTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.CheckoutPreviousBranch),
|
|
Handler: self.checkoutPreviousBranch,
|
|
Description: self.c.Tr.CheckoutPreviousBranch,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.ForceCheckoutBranch),
|
|
Handler: self.forceCheckout,
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.ForceCheckout,
|
|
Tooltip: self.c.Tr.ForceCheckoutTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Remove),
|
|
Handler: self.withItems(self.delete),
|
|
GetDisabledReason: self.require(self.itemRangeSelected(self.branchesAreReal)),
|
|
Description: self.c.Tr.Delete,
|
|
Tooltip: self.c.Tr.BranchDeleteTooltip,
|
|
OpensMenu: true,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.RebaseBranch),
|
|
Handler: opts.Guards.OutsideFilterMode(self.withItem(self.rebase)),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.RebaseBranch,
|
|
Tooltip: self.c.Tr.RebaseBranchTooltip,
|
|
OpensMenu: true,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.MergeIntoCurrentBranch),
|
|
Handler: opts.Guards.OutsideFilterMode(self.merge),
|
|
GetDisabledReason: self.require(self.singleItemSelected(self.notMergingIntoYourself)),
|
|
Description: self.c.Tr.Merge,
|
|
Tooltip: self.c.Tr.MergeBranchTooltip,
|
|
DisplayOnScreen: true,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.FastForward),
|
|
Handler: self.withItem(self.fastForward),
|
|
GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)),
|
|
Description: self.c.Tr.FastForward,
|
|
Tooltip: self.c.Tr.FastForwardTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.CreateTag),
|
|
Handler: self.withItem(self.createTag),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.NewTag,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.SortOrder),
|
|
Handler: self.createSortMenu,
|
|
Description: self.c.Tr.SortOrder,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.ViewResetOptions),
|
|
Handler: self.withItem(self.createResetMenu),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.ViewResetOptions,
|
|
OpensMenu: true,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.RenameBranch),
|
|
Handler: self.withItem(self.rename),
|
|
GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)),
|
|
Description: self.c.Tr.RenameBranch,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Branches.SetUpstream),
|
|
Handler: self.withItem(self.viewUpstreamOptions),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.ViewBranchUpstreamOptions,
|
|
Tooltip: self.c.Tr.ViewBranchUpstreamOptionsTooltip,
|
|
ShortDescription: self.c.Tr.Upstream,
|
|
OpensMenu: true,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.OpenDiffTool),
|
|
Handler: self.withItem(func(selectedBranch *models.Branch) error {
|
|
return self.c.Helpers().Diff.OpenDiffToolForRef(selectedBranch)
|
|
}),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.OpenDiffTool,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (self *BranchesController) GetOnRenderToMain() func() {
|
|
return func() {
|
|
self.c.Helpers().Diff.WithDiffModeCheck(func() {
|
|
var task types.UpdateTask
|
|
branch := self.context().GetSelected()
|
|
if branch == nil {
|
|
task = types.NewRenderStringTask(self.c.Tr.NoBranchesThisRepo)
|
|
} else {
|
|
cmdObj := self.c.Git().Branch.GetGraphCmdObj(branch.FullRefName())
|
|
|
|
ptyTask := types.NewRunPtyTask(cmdObj.GetCmd())
|
|
task = ptyTask
|
|
|
|
pr, ok := self.c.Model().PullRequestsMap[branch.Name]
|
|
if ok && presentation.ShouldShowPrForBranch(pr, branch.Name, self.c.UserConfig()) {
|
|
icon := lo.Ternary(icons.IsIconEnabled(), icons.IconForRemoteUrl(pr.Url)+" ", "")
|
|
ptyTask.Prefix = style.PrintHyperlink(fmt.Sprintf("%s%s %s %s\n",
|
|
icon,
|
|
coloredStateText(pr.State),
|
|
pr.Title,
|
|
style.FgCyan.Sprintf("#%d", pr.Number)),
|
|
pr.Url)
|
|
ptyTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n"
|
|
}
|
|
}
|
|
|
|
self.c.RenderToMainViews(types.RefreshMainOpts{
|
|
Pair: self.c.MainViewPairs().Normal,
|
|
Main: &types.ViewUpdateOpts{
|
|
Title: self.c.Tr.LogTitle,
|
|
Task: task,
|
|
},
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func stateText(state string) string {
|
|
var icon, label string
|
|
switch state {
|
|
case "OPEN":
|
|
icon, label = " ", "Open"
|
|
case "CLOSED":
|
|
icon, label = " ", "Closed"
|
|
case "MERGED":
|
|
icon, label = " ", "Merged"
|
|
case "DRAFT":
|
|
icon, label = " ", "Draft"
|
|
default:
|
|
return ""
|
|
}
|
|
if icons.IsIconEnabled() {
|
|
return icon + label
|
|
}
|
|
return label
|
|
}
|
|
|
|
func coloredStateText(state string) string {
|
|
if icons.IsIconEnabled() {
|
|
return fmt.Sprintf("%s%s%s",
|
|
presentation.WithPrColor(state, "", false),
|
|
presentation.WithPrColor(state, color.RGB(0xFF, 0xFF, 0xFF, false).Sprint(stateText(state)), true),
|
|
presentation.WithPrColor(state, "", false))
|
|
}
|
|
|
|
return presentation.WithPrColor(state, stateText(state), false)
|
|
}
|
|
|
|
func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branch) error {
|
|
upstream := lo.Ternary(selectedBranch.RemoteBranchStoredLocally(),
|
|
selectedBranch.ShortUpstreamRefName(),
|
|
self.c.Tr.UpstreamGenericName)
|
|
|
|
viewDivergenceItem := &types.MenuItem{
|
|
LabelColumns: []string{self.c.Tr.ViewDivergenceFromUpstream},
|
|
OnPress: func() error {
|
|
branch := self.context().GetSelected()
|
|
if branch == nil {
|
|
return nil
|
|
}
|
|
|
|
return self.c.Helpers().SubCommits.ViewSubCommits(helpers.ViewSubCommitsOpts{
|
|
Ref: branch,
|
|
TitleRef: fmt.Sprintf("%s <-> %s", branch.RefName(), upstream),
|
|
RefToShowDivergenceFrom: branch.FullUpstreamRefName(),
|
|
Context: self.context(),
|
|
ShowBranchHeads: false,
|
|
})
|
|
},
|
|
}
|
|
|
|
var disabledReason *types.DisabledReason
|
|
baseBranch, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(selectedBranch, self.c.Model().MainBranches)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if baseBranch == "" {
|
|
baseBranch = self.c.Tr.CouldNotDetermineBaseBranch
|
|
disabledReason = &types.DisabledReason{Text: self.c.Tr.CouldNotDetermineBaseBranch}
|
|
}
|
|
shortBaseBranchName := helpers.ShortBranchName(baseBranch)
|
|
label := utils.ResolvePlaceholderString(
|
|
self.c.Tr.ViewDivergenceFromBaseBranch,
|
|
map[string]string{"baseBranch": shortBaseBranchName},
|
|
)
|
|
viewDivergenceFromBaseBranchItem := &types.MenuItem{
|
|
LabelColumns: []string{label},
|
|
Keys: menuKey('b'),
|
|
OnPress: func() error {
|
|
branch := self.context().GetSelected()
|
|
if branch == nil {
|
|
return nil
|
|
}
|
|
|
|
return self.c.Helpers().SubCommits.ViewSubCommits(helpers.ViewSubCommitsOpts{
|
|
Ref: branch,
|
|
TitleRef: fmt.Sprintf("%s <-> %s", branch.RefName(), shortBaseBranchName),
|
|
RefToShowDivergenceFrom: baseBranch,
|
|
Context: self.context(),
|
|
ShowBranchHeads: false,
|
|
})
|
|
},
|
|
DisabledReason: disabledReason,
|
|
}
|
|
|
|
unsetUpstreamItem := &types.MenuItem{
|
|
LabelColumns: []string{self.c.Tr.UnsetUpstream},
|
|
OnPress: func() error {
|
|
if err := self.c.Git().Branch.UnsetUpstream(selectedBranch.Name); err != nil {
|
|
return err
|
|
}
|
|
self.c.Refresh(types.RefreshOptions{
|
|
Mode: types.SYNC,
|
|
Scope: []types.RefreshableView{
|
|
types.BRANCHES,
|
|
types.COMMITS,
|
|
},
|
|
})
|
|
return nil
|
|
},
|
|
Keys: menuKey('u'),
|
|
}
|
|
|
|
setUpstreamItem := &types.MenuItem{
|
|
LabelColumns: []string{self.c.Tr.SetUpstream},
|
|
OnPress: func() error {
|
|
return self.c.Helpers().Upstream.PromptForUpstreamWithoutInitialContent(selectedBranch, func(upstream string) error {
|
|
upstreamRemote, upstreamBranch, err := self.c.Helpers().Upstream.ParseUpstream(upstream)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := self.c.Git().Branch.SetUpstream(upstreamRemote, upstreamBranch, selectedBranch.Name); err != nil {
|
|
return err
|
|
}
|
|
self.c.Refresh(types.RefreshOptions{
|
|
Mode: types.SYNC,
|
|
Scope: []types.RefreshableView{
|
|
types.BRANCHES,
|
|
types.COMMITS,
|
|
},
|
|
})
|
|
return nil
|
|
})
|
|
},
|
|
Keys: menuKey('s'),
|
|
}
|
|
|
|
upstreamResetOptions := utils.ResolvePlaceholderString(
|
|
self.c.Tr.ViewUpstreamResetOptions,
|
|
map[string]string{"upstream": upstream},
|
|
)
|
|
upstreamResetTooltip := utils.ResolvePlaceholderString(
|
|
self.c.Tr.ViewUpstreamResetOptionsTooltip,
|
|
map[string]string{"upstream": upstream},
|
|
)
|
|
|
|
upstreamRebaseOptions := utils.ResolvePlaceholderString(
|
|
self.c.Tr.ViewUpstreamRebaseOptions,
|
|
map[string]string{"upstream": upstream},
|
|
)
|
|
upstreamRebaseTooltip := utils.ResolvePlaceholderString(
|
|
self.c.Tr.ViewUpstreamRebaseOptionsTooltip,
|
|
map[string]string{"upstream": upstream},
|
|
)
|
|
|
|
upstreamResetItem := &types.MenuItem{
|
|
LabelColumns: []string{upstreamResetOptions},
|
|
OpensMenu: true,
|
|
OnPress: func() error {
|
|
// We only can invoke this when the remote branch is stored locally, so using the selectedBranch here is fine.
|
|
err := self.c.Helpers().Refs.CreateGitResetMenu(upstream, selectedBranch.FullUpstreamRefName())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
},
|
|
Tooltip: upstreamResetTooltip,
|
|
Keys: menuKey('g'),
|
|
}
|
|
|
|
upstreamRebaseItem := &types.MenuItem{
|
|
LabelColumns: []string{upstreamRebaseOptions},
|
|
OpensMenu: true,
|
|
OnPress: func() error {
|
|
if err := self.c.Helpers().MergeAndRebase.RebaseOntoRef(upstream); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
},
|
|
Tooltip: upstreamRebaseTooltip,
|
|
Keys: menuKey('r'),
|
|
}
|
|
|
|
if !selectedBranch.IsTrackingRemote() {
|
|
unsetUpstreamItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError}
|
|
}
|
|
|
|
if !selectedBranch.RemoteBranchStoredLocally() {
|
|
viewDivergenceItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError}
|
|
upstreamResetItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError}
|
|
upstreamRebaseItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError}
|
|
}
|
|
|
|
options := []*types.MenuItem{
|
|
viewDivergenceItem,
|
|
viewDivergenceFromBaseBranchItem,
|
|
unsetUpstreamItem,
|
|
setUpstreamItem,
|
|
upstreamResetItem,
|
|
upstreamRebaseItem,
|
|
}
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.BranchUpstreamOptionsTitle,
|
|
Items: options,
|
|
})
|
|
}
|
|
|
|
func (self *BranchesController) Context() types.Context {
|
|
return self.context()
|
|
}
|
|
|
|
func (self *BranchesController) context() *context.BranchesContext {
|
|
return self.c.Contexts().Branches
|
|
}
|
|
|
|
func (self *BranchesController) press(selectedBranch *models.Branch) error {
|
|
if selectedBranch == self.c.Helpers().Refs.GetCheckedOutRef() {
|
|
return errors.New(self.c.Tr.AlreadyCheckedOutBranch)
|
|
}
|
|
|
|
worktreeForRef, ok := self.worktreeForBranch(selectedBranch)
|
|
if ok && !worktreeForRef.IsCurrent {
|
|
return self.promptToCheckoutWorktree(worktreeForRef)
|
|
}
|
|
|
|
self.c.LogAction(self.c.Tr.Actions.CheckoutBranch)
|
|
return self.c.Helpers().Refs.CheckoutRef(selectedBranch.Name, types.CheckoutRefOptions{})
|
|
}
|
|
|
|
func (self *BranchesController) notPulling() *types.DisabledReason {
|
|
currentBranch := self.c.Helpers().Refs.GetCheckedOutRef()
|
|
if currentBranch != nil {
|
|
op := self.c.State().GetItemOperation(currentBranch)
|
|
if op == types.ItemOperationFastForwarding || op == types.ItemOperationPulling {
|
|
return &types.DisabledReason{Text: self.c.Tr.CantCheckoutBranchWhilePulling}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *BranchesController) worktreeForBranch(branch *models.Branch) (*models.Worktree, bool) {
|
|
return git_commands.WorktreeForBranch(branch, self.c.Model().Worktrees)
|
|
}
|
|
|
|
func (self *BranchesController) promptToCheckoutWorktree(worktree *models.Worktree) error {
|
|
prompt := utils.ResolvePlaceholderString(self.c.Tr.AlreadyCheckedOutByWorktree, map[string]string{
|
|
"worktreeName": worktree.Name,
|
|
})
|
|
|
|
return self.c.ConfirmIf(!self.c.UserConfig().Gui.SkipSwitchWorktreeOnCheckoutWarning, types.ConfirmOpts{
|
|
Title: self.c.Tr.SwitchToWorktree,
|
|
Prompt: prompt,
|
|
HandleConfirm: func() error {
|
|
return self.c.Helpers().Worktree.Switch(worktree, context.LOCAL_BRANCHES_CONTEXT_KEY)
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *BranchesController) handleCreatePullRequest(selectedBranch *models.Branch) error {
|
|
if !selectedBranch.IsTrackingRemote() {
|
|
return errors.New(self.c.Tr.PullRequestNoUpstream)
|
|
}
|
|
return self.createPullRequest(selectedBranch.UpstreamBranch, "")
|
|
}
|
|
|
|
func (self *BranchesController) handleCreatePullRequestMenu(selectedBranch *models.Branch) error {
|
|
checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef()
|
|
|
|
return self.createPullRequestMenu(selectedBranch, checkedOutBranch)
|
|
}
|
|
|
|
func (self *BranchesController) getPullRequestURL() (string, error) {
|
|
branch := self.context().GetSelected()
|
|
if pr, ok := self.c.Model().PullRequestsMap[branch.Name]; ok {
|
|
return pr.Url, nil
|
|
}
|
|
|
|
branchExistsOnRemote := self.c.Git().Remote.CheckRemoteBranchExists(branch.Name)
|
|
|
|
if !branchExistsOnRemote {
|
|
return "", errors.New(self.c.Tr.NoBranchOnRemote)
|
|
}
|
|
|
|
return self.c.Helpers().Host.GetPullRequestURL(branch.Name, "")
|
|
}
|
|
|
|
func (self *BranchesController) copyPullRequestURL() error {
|
|
url, err := self.getPullRequestURL()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
self.c.LogAction(self.c.Tr.Actions.CopyPullRequestURL)
|
|
if err := self.c.OS().CopyToClipboard(url); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.Toast(self.c.Tr.PullRequestURLCopiedToClipboard)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *BranchesController) forceCheckout() error {
|
|
branch := self.context().GetSelected()
|
|
message := self.c.Tr.SureForceCheckout
|
|
title := self.c.Tr.ForceCheckoutBranch
|
|
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: title,
|
|
Prompt: message,
|
|
HandleConfirm: func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.ForceCheckoutBranch)
|
|
if err := self.c.Git().Branch.Checkout(branch.Name, git_commands.CheckoutOptions{Force: true}); err != nil {
|
|
return err
|
|
}
|
|
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
|
|
return nil
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *BranchesController) checkoutPreviousBranch() error {
|
|
self.c.LogAction(self.c.Tr.Actions.CheckoutBranch)
|
|
return self.c.Helpers().Refs.CheckoutPreviousRef()
|
|
}
|
|
|
|
func (self *BranchesController) checkoutByName() error {
|
|
self.c.Prompt(types.PromptOpts{
|
|
Title: self.c.Tr.BranchName + ":",
|
|
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRefsSuggestionsFunc(),
|
|
HandleConfirm: func(response string) error {
|
|
self.c.LogAction("Checkout branch")
|
|
_, branchName, found := self.c.Helpers().Refs.ParseRemoteBranchName(response)
|
|
if found {
|
|
return self.c.Helpers().Refs.CheckoutRemoteBranch(response, branchName)
|
|
}
|
|
return self.c.Helpers().Refs.CheckoutRef(response, types.CheckoutRefOptions{
|
|
OnRefNotFound: func(ref string) error {
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: self.c.Tr.BranchNotFoundTitle,
|
|
Prompt: fmt.Sprintf("%s %s%s", self.c.Tr.BranchNotFoundPrompt, ref, "?"),
|
|
HandleConfirm: func() error {
|
|
return self.createNewBranchWithName(ref)
|
|
},
|
|
})
|
|
|
|
return nil
|
|
},
|
|
})
|
|
},
|
|
},
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *BranchesController) createNewBranchWithName(newBranchName string) error {
|
|
branch := self.context().GetSelected()
|
|
if branch == nil {
|
|
return nil
|
|
}
|
|
|
|
if err := self.c.Git().Branch.New(newBranchName, branch.FullRefName()); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.Refresh(types.RefreshOptions{
|
|
Mode: types.ASYNC,
|
|
BranchSelection: types.SelectCheckedOutBranch,
|
|
CommitSelection: types.SelectHeadCommit,
|
|
SelectTopReflogCommit: true,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (self *BranchesController) localDelete(branches []*models.Branch) error {
|
|
return self.c.Helpers().BranchesHelper.ConfirmLocalDelete(branches)
|
|
}
|
|
|
|
func (self *BranchesController) remoteDelete(branches []*models.Branch) error {
|
|
remoteBranches := lo.Map(branches, func(branch *models.Branch, _ int) *models.RemoteBranch {
|
|
return &models.RemoteBranch{Name: branch.UpstreamBranch, RemoteName: branch.UpstreamRemote}
|
|
})
|
|
return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(remoteBranches, false)
|
|
}
|
|
|
|
func (self *BranchesController) localAndRemoteDelete(branches []*models.Branch) error {
|
|
return self.c.Helpers().BranchesHelper.ConfirmLocalAndRemoteDelete(branches)
|
|
}
|
|
|
|
func (self *BranchesController) delete(branches []*models.Branch) error {
|
|
checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef()
|
|
isBranchCheckedOut := lo.SomeBy(branches, func(branch *models.Branch) bool {
|
|
return checkedOutBranch.Name == branch.Name
|
|
})
|
|
hasUpstream := lo.EveryBy(branches, func(branch *models.Branch) bool {
|
|
return branch.IsTrackingRemote() && !branch.UpstreamGone
|
|
})
|
|
|
|
localDeleteItem := &types.MenuItem{
|
|
Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteLocalBranches, self.c.Tr.DeleteLocalBranch),
|
|
Keys: menuKey('c'),
|
|
OnPress: func() error {
|
|
return self.localDelete(branches)
|
|
},
|
|
}
|
|
if isBranchCheckedOut {
|
|
localDeleteItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CantDeleteCheckOutBranch}
|
|
}
|
|
|
|
remoteDeleteItem := &types.MenuItem{
|
|
Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteRemoteBranches, self.c.Tr.DeleteRemoteBranch),
|
|
Keys: menuKey('r'),
|
|
OnPress: func() error {
|
|
return self.remoteDelete(branches)
|
|
},
|
|
}
|
|
if !hasUpstream {
|
|
remoteDeleteItem.DisabledReason = &types.DisabledReason{
|
|
Text: lo.Ternary(len(branches) > 1, self.c.Tr.UpstreamsNotSetError, self.c.Tr.UpstreamNotSetError),
|
|
}
|
|
}
|
|
|
|
deleteBothItem := &types.MenuItem{
|
|
Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteLocalAndRemoteBranches, self.c.Tr.DeleteLocalAndRemoteBranch),
|
|
Keys: menuKey('b'),
|
|
OnPress: func() error {
|
|
return self.localAndRemoteDelete(branches)
|
|
},
|
|
}
|
|
if isBranchCheckedOut {
|
|
deleteBothItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CantDeleteCheckOutBranch}
|
|
} else if !hasUpstream {
|
|
deleteBothItem.DisabledReason = &types.DisabledReason{
|
|
Text: lo.Ternary(len(branches) > 1, self.c.Tr.UpstreamsNotSetError, self.c.Tr.UpstreamNotSetError),
|
|
}
|
|
}
|
|
|
|
var menuTitle string
|
|
if len(branches) == 1 {
|
|
menuTitle = utils.ResolvePlaceholderString(
|
|
self.c.Tr.DeleteBranchTitle,
|
|
map[string]string{
|
|
"selectedBranchName": branches[0].Name,
|
|
},
|
|
)
|
|
} else {
|
|
menuTitle = self.c.Tr.DeleteBranchesTitle
|
|
}
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: menuTitle,
|
|
Items: []*types.MenuItem{localDeleteItem, remoteDeleteItem, deleteBothItem},
|
|
})
|
|
}
|
|
|
|
func (self *BranchesController) merge() error {
|
|
selectedBranchName := self.context().GetSelected().Name
|
|
return self.c.Helpers().MergeAndRebase.MergeRefIntoCheckedOutBranch(selectedBranchName)
|
|
}
|
|
|
|
func (self *BranchesController) rebase(branch *models.Branch) error {
|
|
return self.c.Helpers().MergeAndRebase.RebaseOntoRef(branch.Name)
|
|
}
|
|
|
|
func (self *BranchesController) fastForward(branch *models.Branch) error {
|
|
if !branch.IsTrackingRemote() {
|
|
return errors.New(self.c.Tr.FwdNoUpstream)
|
|
}
|
|
if !branch.RemoteBranchStoredLocally() {
|
|
return errors.New(self.c.Tr.FwdNoLocalUpstream)
|
|
}
|
|
if branch.IsAheadForPull() {
|
|
return errors.New(self.c.Tr.FwdCommitsToPush)
|
|
}
|
|
|
|
action := self.c.Tr.Actions.FastForwardBranch
|
|
|
|
return self.c.WithInlineStatus(branch, types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
|
|
worktree, ok := self.worktreeForBranch(branch)
|
|
if ok {
|
|
self.c.LogAction(action)
|
|
|
|
worktreeGitDir := ""
|
|
worktreePath := ""
|
|
// if it is the current worktree path, no need to specify the path
|
|
if !worktree.IsCurrent {
|
|
worktreeGitDir = worktree.GitDir
|
|
worktreePath = worktree.Path
|
|
}
|
|
|
|
err := self.c.Git().Sync.Pull(
|
|
task,
|
|
git_commands.PullOptions{
|
|
RemoteName: branch.UpstreamRemote,
|
|
BranchName: branch.UpstreamBranch,
|
|
FastForwardOnly: true,
|
|
WorktreeGitDir: worktreeGitDir,
|
|
WorktreePath: worktreePath,
|
|
},
|
|
)
|
|
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC})
|
|
return err
|
|
}
|
|
|
|
self.c.LogAction(action)
|
|
|
|
err := self.c.Git().Sync.FastForward(
|
|
task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch,
|
|
)
|
|
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES}})
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (self *BranchesController) createTag(branch *models.Branch) error {
|
|
return self.c.Helpers().Tags.OpenCreateTagPrompt(branch.FullRefName(), func() {})
|
|
}
|
|
|
|
func (self *BranchesController) createSortMenu() error {
|
|
return self.c.Helpers().Refs.CreateSortOrderMenu(
|
|
[]string{"recency", "alphabetical", "date"},
|
|
self.c.Tr.SortOrderPromptLocalBranches,
|
|
func(sortOrder string) error {
|
|
if self.c.UserConfig().Git.LocalBranchSortOrder != sortOrder {
|
|
self.c.UserConfig().Git.LocalBranchSortOrder = sortOrder
|
|
self.c.Contexts().Branches.SetSelection(0)
|
|
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}})
|
|
return nil
|
|
}
|
|
return nil
|
|
},
|
|
self.c.UserConfig().Git.LocalBranchSortOrder)
|
|
}
|
|
|
|
func (self *BranchesController) createResetMenu(selectedBranch *models.Branch) error {
|
|
return self.c.Helpers().Refs.CreateGitResetMenu(selectedBranch.Name, selectedBranch.FullRefName())
|
|
}
|
|
|
|
func (self *BranchesController) rename(branch *models.Branch) error {
|
|
promptForNewName := func() error {
|
|
self.c.Prompt(types.PromptOpts{
|
|
Title: self.c.Tr.NewBranchNamePrompt + " " + branch.Name + ":",
|
|
InitialContent: branch.Name,
|
|
HandleConfirm: func(newBranchName string) error {
|
|
self.c.LogAction(self.c.Tr.Actions.RenameBranch)
|
|
if err := self.c.Git().Branch.Rename(branch.Name, helpers.SanitizedBranchName(newBranchName)); err != nil {
|
|
return err
|
|
}
|
|
|
|
// need to find where the branch is now so that we can re-select it. That means we need to
|
|
// refetch the branches and then find our branch. The branches model update is bounced
|
|
// onto the UI thread, so the re-selection (which reads Model.Branches) has to run in
|
|
// Then; reading it inline here would see the previous model.
|
|
self.c.Refresh(types.RefreshOptions{
|
|
Mode: types.SYNC,
|
|
Scope: []types.RefreshableView{types.BRANCHES, types.WORKTREES},
|
|
Then: func() error {
|
|
// now that we've got our stuff again we need to find that branch and reselect it.
|
|
for i, newBranch := range self.c.Model().Branches {
|
|
if newBranch.Name == newBranchName {
|
|
self.context().SetSelection(i)
|
|
self.context().HandleRender()
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
})
|
|
|
|
return nil
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// I could do an explicit check here for whether the branch is tracking a remote branch
|
|
// but if we've selected it we'll already know that via Pullables and Pullables.
|
|
// Bit of a hack but I'm lazy.
|
|
return self.c.ConfirmIf(branch.IsTrackingRemote(), types.ConfirmOpts{
|
|
Title: self.c.Tr.RenameBranch,
|
|
Prompt: self.c.Tr.RenameBranchWarning,
|
|
HandleConfirm: promptForNewName,
|
|
})
|
|
}
|
|
|
|
func (self *BranchesController) newBranch(selectedBranch *models.Branch) error {
|
|
return self.c.Helpers().Refs.NewBranch(selectedBranch.FullRefName(), selectedBranch.RefName(), "")
|
|
}
|
|
|
|
func (self *BranchesController) createPullRequestMenu(selectedBranch *models.Branch, checkedOutBranch *models.Branch) error {
|
|
menuItems := make([]*types.MenuItem, 0, 4)
|
|
|
|
fromToLabelColumns := func(from string, to string) []string {
|
|
return []string{fmt.Sprintf("%s → %s", from, to)}
|
|
}
|
|
|
|
menuItemsForBranch := func(branch *models.Branch) []*types.MenuItem {
|
|
return []*types.MenuItem{
|
|
{
|
|
LabelColumns: fromToLabelColumns(branch.Name, self.c.Tr.DefaultBranch),
|
|
OnPress: func() error {
|
|
return self.handleCreatePullRequest(branch)
|
|
},
|
|
},
|
|
{
|
|
LabelColumns: fromToLabelColumns(branch.Name, self.c.Tr.SelectBranch),
|
|
OnPress: func() error {
|
|
if !branch.IsTrackingRemote() {
|
|
return errors.New(self.c.Tr.PullRequestNoUpstream)
|
|
}
|
|
|
|
if len(self.c.Model().Remotes) == 1 {
|
|
toRemote := self.c.Model().Remotes[0].Name
|
|
self.c.Log.Debugf("PR will target the only existing remote '%s'", toRemote)
|
|
return self.promptForTargetBranchNameAndCreatePullRequest(branch, toRemote)
|
|
}
|
|
|
|
self.c.Prompt(types.PromptOpts{
|
|
Title: self.c.Tr.SelectTargetRemote,
|
|
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(),
|
|
HandleConfirm: func(toRemote string) error {
|
|
self.c.Log.Debugf("PR will target remote '%s'", toRemote)
|
|
|
|
return self.promptForTargetBranchNameAndCreatePullRequest(branch, toRemote)
|
|
},
|
|
})
|
|
|
|
return nil
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
if selectedBranch != checkedOutBranch {
|
|
menuItems = append(menuItems,
|
|
&types.MenuItem{
|
|
LabelColumns: fromToLabelColumns(checkedOutBranch.Name, selectedBranch.Name),
|
|
OnPress: func() error {
|
|
if !checkedOutBranch.IsTrackingRemote() || !selectedBranch.IsTrackingRemote() {
|
|
return errors.New(self.c.Tr.PullRequestNoUpstream)
|
|
}
|
|
return self.createPullRequest(checkedOutBranch.UpstreamBranch, selectedBranch.UpstreamBranch)
|
|
},
|
|
},
|
|
)
|
|
menuItems = append(menuItems, menuItemsForBranch(checkedOutBranch)...)
|
|
}
|
|
|
|
menuItems = append(menuItems, menuItemsForBranch(selectedBranch)...)
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{Title: fmt.Sprint(self.c.Tr.CreatePullRequestOptions), Items: menuItems})
|
|
}
|
|
|
|
func (self *BranchesController) promptForTargetBranchNameAndCreatePullRequest(fromBranch *models.Branch, toRemote string) error {
|
|
remoteDoesNotExist := lo.NoneBy(self.c.Model().Remotes, func(remote *models.Remote) bool {
|
|
return remote.Name == toRemote
|
|
})
|
|
if remoteDoesNotExist {
|
|
return fmt.Errorf(self.c.Tr.NoValidRemoteName, toRemote)
|
|
}
|
|
|
|
self.c.Prompt(types.PromptOpts{
|
|
Title: fmt.Sprintf("%s → %s/", fromBranch.UpstreamBranch, toRemote),
|
|
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteBranchesForRemoteSuggestionsFunc(toRemote),
|
|
HandleConfirm: func(toBranch string) error {
|
|
self.c.Log.Debugf("PR will target branch '%s' on remote '%s'", toBranch, toRemote)
|
|
return self.createPullRequest(fromBranch.UpstreamBranch, toBranch)
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *BranchesController) createPullRequest(from string, to string) error {
|
|
url, err := self.c.Helpers().Host.GetPullRequestURL(from, to)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.LogAction(self.c.Tr.Actions.OpenPullRequest)
|
|
|
|
if err := self.c.OS().OpenLink(url); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *BranchesController) branchIsReal(branch *models.Branch) *types.DisabledReason {
|
|
if !branch.IsRealBranch() {
|
|
return &types.DisabledReason{Text: self.c.Tr.SelectedItemIsNotABranch}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *BranchesController) branchHasPR(branch *models.Branch) *types.DisabledReason {
|
|
if _, ok := self.c.Model().PullRequestsMap[branch.Name]; !ok {
|
|
return &types.DisabledReason{Text: self.c.Tr.NoPullRequestForBranch, ShowErrorInPanel: true}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *BranchesController) openPRInBrowser(branch *models.Branch) error {
|
|
pr, ok := self.c.Model().PullRequestsMap[branch.Name]
|
|
if !ok {
|
|
// Should be guarded against by the DisabledReason check, but be defensive in case
|
|
// PullRequestsMap was updated concurrently by a background refresh
|
|
return errors.New(self.c.Tr.NoPullRequestForBranch)
|
|
}
|
|
|
|
self.c.LogAction(self.c.Tr.Actions.OpenPullRequest)
|
|
|
|
return self.c.OS().OpenLink(pr.Url)
|
|
}
|
|
|
|
func (self *BranchesController) branchesAreReal(selectedBranches []*models.Branch, startIdx int, endIdx int) *types.DisabledReason {
|
|
if !lo.EveryBy(selectedBranches, func(branch *models.Branch) bool {
|
|
return branch.IsRealBranch()
|
|
}) {
|
|
return &types.DisabledReason{Text: self.c.Tr.SelectedItemIsNotABranch}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *BranchesController) notMergingIntoYourself(branch *models.Branch) *types.DisabledReason {
|
|
selectedBranchName := branch.Name
|
|
checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef().Name
|
|
|
|
if checkedOutBranch == selectedBranchName {
|
|
return &types.DisabledReason{Text: self.c.Tr.CantMergeBranchIntoItself}
|
|
}
|
|
|
|
return nil
|
|
}
|