mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
Operations that show an inline status ("Pushing", "Fast-forwarding",
"Fetching", …) removed it by relying on the async refresh they trigger
to redraw the view after the item operation had been cleared. That
ordering was never guaranteed: the item operation is cleared on the
worker once the operation's function returns, while the refresh redraws
the item from the UI thread whenever its (asynchronous) git work
happens to finish. If the refresh redrew before the clear, the status
was left on screen with no later redraw to remove it, so the branch (or
tag/remote) stayed stuck showing e.g. "Pushing" indefinitely even though
the operation had completed. This is timing-dependent, which is why it
surfaced as rare, hard-to-reproduce reports and as flaky CI failures.
Fix it by re-rendering in stop() right after clearing the operation,
and by making these refreshes synchronous rather than async. Because a
synchronous refresh has already updated the model and queued its own
redraw by the time stop() runs, and UI-thread callbacks run in order,
the redraw we queue here runs last and draws the up-to-date model with
the status removed. An async refresh couldn't give that guarantee: its
model update might not have landed yet, so the redraw could briefly
flash the pre-operation status.
Pull refreshes through the shared CheckMergeOrRebaseAndSelectHeadCommit,
so that helper becomes synchronous too; its only other caller,
RegularMerge, thereby also refreshes synchronously, which is fine: a
synchronous on-worker refresh is what we want anyway.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
969 lines
31 KiB
Go
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.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 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
|
|
}
|