jesseduffield.lazygit/pkg/gui/controllers/branches_controller.go
Stefan Haller 737fb98967 Move the new-worktree keybinding from worktrees to universal
The command was renamed from "View worktree options" to "New worktree",
but its keybinding config key was still 'worktrees.viewWorktreeOptions'.
That name no longer matches the command, and the 'worktrees' section made
little sense: it held a single binding that isn't even used in the
worktrees panel (that panel uses universal.new), only in the branches,
remotes, tags, commits, and stash panels. Other keybinding sections are
named after the panel they're local to; this one wasn't local to any.

Move it to universal.newWorktree, which describes the action and drops the
spurious section, and migrate existing configs automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:53:05 +02:00

969 lines
31 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.Helpers().Refs.SelectFirstBranchAndFirstCommit()
self.c.Refresh(types.RefreshOptions{
Mode: types.ASYNC,
KeepBranchSelectionIndex: true,
CommitSelection: types.KeepCommitSelectionIndex,
})
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.ASYNC})
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.ASYNC, 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 synchronously and then find our branch
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{types.BRANCHES, types.WORKTREES},
})
// 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
}
// 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
}