Files
lazygit/pkg/gui/controllers/sync_controller.go
T
Stefan HallerandClaude Fable 5.1 9a585bf11e Offer to push the branches stacked below the current one
After rebasing a stack of branches, every branch of the stack has to be
force-pushed, and so far the only way to do that in lazygit was to check
out each branch in turn and push it. For a stack of a dozen branches
that is a lot of work for a routine task.

When the current branch is pushed and there are branches below it in the
stack that have commits to push, show a menu that offers to push them
along with it. The menu lists the branches and how far each of them has
diverged from its remote branch, so that the user can see what is about
to happen. It doesn't show where each branch goes; the branches panel
doesn't show that for a normal push either. Pushing only the current
branch is the second entry. If any of the branches has diverged from its
remote branch, a single confirmation covers force-pushing all of them.

A branch is offered if its tip is a commit of the current branch that
isn't merged yet, it has an upstream that is stored locally, that
upstream is not gone, and it is ahead of its push destination. Branches
without an upstream are left out because a branch that was never pushed
can't be told apart from one that is meant to stay local. A branch that
is only behind is left out because pushing it would move the remote
branch back to an older commit; the lease doesn't catch that when the
remote-tracking branch is up to date.

Each branch is pushed to where `git push` would push it if it were
checked out, using the destination git reports in the %(push) field.
This honors push.default, remote.pushDefault and
branch.<name>.pushRemote without lazygit having to interpret them.
Branches going to the same remote are pushed in one command, with
--force-with-lease when the user confirmed force-pushing; the lease
checks each ref against its remote-tracking branch, so a coworker's
unseen push is still rejected. The current branch joins that command
when its remote-tracking branch is stored locally. Otherwise it needs a
plain push first, for example with --set-upstream, and that option
would apply to every refspec of a combined command; so in that case it
is pushed on its own as before, and the other branches follow in a
second command.

Pushing the current branch on its own still runs a bare `git push`, so
users who rely on push.default or remote.<name>.push for it see no
change. The push is non-atomic, as git defaults to; if one branch's
lease fails, the others still go through and the error names the one to
look at.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 08:21:02 +02:00

448 lines
15 KiB
Go

package controllers
import (
"errors"
"fmt"
"strings"
"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/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
type SyncController struct {
baseController
c *ControllerCommon
}
var _ types.IController = &SyncController{}
func NewSyncController(
common *ControllerCommon,
) *SyncController {
return &SyncController{
baseController: baseController{},
c: common,
}
}
func (self *SyncController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Universal.Push),
Handler: opts.Guards.NoPopupPanel(self.HandlePush),
GetDisabledReason: self.getDisabledReasonForPushOrPull,
Description: self.c.Tr.Push,
Tooltip: self.c.Tr.PushTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Pull),
Handler: opts.Guards.NoPopupPanel(self.HandlePull),
GetDisabledReason: self.getDisabledReasonForPushOrPull,
Description: self.c.Tr.Pull,
Tooltip: self.c.Tr.PullTooltip,
},
}
return bindings
}
func (self *SyncController) Context() types.Context {
return nil
}
func (self *SyncController) HandlePush() error {
return self.branchCheckedOut(self.push)()
}
func (self *SyncController) HandlePull() error {
return self.branchCheckedOut(self.pull)()
}
func (self *SyncController) getDisabledReasonForPushOrPull() *types.DisabledReason {
currentBranch := self.c.Helpers().Refs.GetCheckedOutRef()
if currentBranch != nil {
op := self.c.State().GetItemOperation(currentBranch)
if op != types.ItemOperationNone {
return &types.DisabledReason{Text: self.c.Tr.CantPullOrPushSameBranchTwice}
}
}
return nil
}
func (self *SyncController) branchCheckedOut(f func(*models.Branch) error) func() error {
return func() error {
currentBranch := self.c.Helpers().Refs.GetCheckedOutRef()
if currentBranch == nil {
// need to wait for branches to refresh
return nil
}
return f(currentBranch)
}
}
func (self *SyncController) push(currentBranch *models.Branch) error {
branchesBelow := self.unpushedBranchesBelow(currentBranch)
if len(branchesBelow) == 0 {
return self.pushCurrentBranch(currentBranch)
}
branchName := map[string]string{"branchName": currentBranch.Name}
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.Push,
Prompt: self.branchesBelowPrompt(currentBranch, branchesBelow),
Items: []*types.MenuItem{
{
Label: utils.ResolvePlaceholderString(self.c.Tr.PushBranchAndBranchesBelow, branchName),
OnPress: func() error {
return self.pushWithBranchesBelow(currentBranch, branchesBelow)
},
},
{
Label: utils.ResolvePlaceholderString(self.c.Tr.PushOnlyCurrentBranch, branchName),
OnPress: func() error {
return self.pushCurrentBranch(currentBranch)
},
},
},
})
}
func (self *SyncController) pushCurrentBranch(currentBranch *models.Branch) error {
return self.resolvePushOfCurrentBranch(currentBranch, func(opts pushOpts) error {
// if we are behind our upstream branch we'll ask if the user wants to force push
if currentBranch.IsBehindForPush() {
return self.requestToForcePush(currentBranch, opts)
}
return self.pushAux(currentBranch, opts)
})
}
// The branches stacked below the current one that have commits to push
func (self *SyncController) unpushedBranchesBelow(currentBranch *models.Branch) []*models.Branch {
branchesBelow := helpers.BranchesBelowInStack(
self.c.Model().Commits, self.c.Model().Branches, currentBranch, self.c.UserConfig().Git.MainBranches)
return lo.Filter(branchesBelow, func(branch *models.Branch, _ int) bool {
// Pushing a branch whose remote branch was deleted would recreate it.
// A branch that is only behind its remote branch has nothing to push,
// and force-pushing it would move the remote branch back to an older
// commit.
return branch.PushRemote != "" && !branch.UpstreamGone && branch.IsAheadForPush()
})
}
func (self *SyncController) branchesBelowPrompt(currentBranch *models.Branch, branchesBelow []*models.Branch) string {
intro := utils.ResolvePlaceholderString(
self.c.Tr.BranchesBelowHaveCommitsToPush,
map[string]string{"branchName": currentBranch.Name},
)
lines := lo.Map(branchesBelow, func(branch *models.Branch, _ int) string {
divergence := "↑" + branch.AheadForPush
if branch.IsBehindForPush() {
divergence = "↓" + branch.BehindForPush + divergence
}
return fmt.Sprintf("%s %s", branch.Name, style.FgYellow.Sprint(divergence))
})
return intro + "\n\n " + strings.Join(lines, "\n ")
}
// Pushes the current branch and the branches stacked below it, after asking
// for confirmation if any of them has to be force-pushed
func (self *SyncController) pushWithBranchesBelow(currentBranch *models.Branch, branchesBelow []*models.Branch) error {
if currentBranch.RemoteBranchStoredLocally() && currentBranch.PushRemote != "" {
// We know where the current branch goes and whether it needs to be
// forced, so it is pushed like the branches below it, in the same
// command as those that go to the same remote
branches := append([]*models.Branch{currentBranch}, branchesBelow...)
return self.confirmForcePushIfNeeded(branches, func(forceWithLease bool) error {
return self.pushBranchesAux(currentBranch, branchesBelow, forceWithLease)
})
}
// The current branch has no upstream yet, or its remote branch isn't
// stored locally. Push it the way it is pushed on its own, and the branches
// below it after it.
return self.resolvePushOfCurrentBranch(currentBranch, func(opts pushOpts) error {
return self.confirmForcePushIfNeeded(branchesBelow, func(forceWithLease bool) error {
opts.branchesBelow = branchesBelow
opts.forceWithLeaseBelow = forceWithLease
return self.pushAux(currentBranch, opts)
})
})
}
// Calls push right away if none of the branches has diverged from its remote
// branch, and after the user confirmed force-pushing otherwise
func (self *SyncController) confirmForcePushIfNeeded(branches []*models.Branch, push func(forceWithLease bool) error) error {
diverged := lo.Filter(branches, func(branch *models.Branch, _ int) bool {
return branch.IsBehindForPush()
})
if len(diverged) == 0 {
return push(false)
}
if self.c.UserConfig().Git.DisableForcePushing {
return errors.New(self.c.Tr.ForcePushBranchesDisabled)
}
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.ForcePush,
Prompt: utils.ResolvePlaceholderString(
self.c.Tr.ForcePushBranchesPrompt,
map[string]string{
"branches": " " + strings.Join(lo.Map(diverged, func(branch *models.Branch, _ int) string { return branch.Name }), "\n "),
"cancelKey": self.c.UserConfig().Keybinding.Universal.Return.String(),
"confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm.String(),
},
),
HandleConfirm: func() error {
return push(true)
},
})
return nil
}
// Works out where the current branch is pushed to: to its upstream, to a
// branch of the same name if push.default is "current", or to an upstream the
// user enters in a prompt. Calls onResolved with the options for that push.
func (self *SyncController) resolvePushOfCurrentBranch(currentBranch *models.Branch, onResolved func(pushOpts) error) error {
if currentBranch.IsTrackingRemote() {
return onResolved(pushOpts{remoteBranchStoredLocally: currentBranch.RemoteBranchStoredLocally()})
}
if self.c.Git().Config.GetPushToCurrent() {
return onResolved(pushOpts{setUpstream: true})
}
return self.c.Helpers().Upstream.PromptForUpstreamWithInitialContent(currentBranch, func(upstream string) error {
upstreamRemote, upstreamBranch, err := self.c.Helpers().Upstream.ParseUpstream(upstream)
if err != nil {
return err
}
return onResolved(pushOpts{
setUpstream: true,
upstreamRemote: upstreamRemote,
upstreamBranch: upstreamBranch,
})
})
}
func (self *SyncController) pull(currentBranch *models.Branch) error {
action := self.c.Tr.Actions.Pull
// if we have no upstream branch we need to set that first
if !currentBranch.IsTrackingRemote() {
return self.c.Helpers().Upstream.PromptForUpstreamWithInitialContent(currentBranch, func(upstream string) error {
if err := self.setCurrentBranchUpstream(upstream); err != nil {
return err
}
return self.PullAux(currentBranch, PullFilesOptions{Action: action})
})
}
return self.PullAux(currentBranch, PullFilesOptions{Action: action})
}
func (self *SyncController) setCurrentBranchUpstream(upstream string) error {
upstreamRemote, upstreamBranch, err := self.c.Helpers().Upstream.ParseUpstream(upstream)
if err != nil {
return err
}
if err := self.c.Git().Branch.SetCurrentBranchUpstream(upstreamRemote, upstreamBranch); err != nil {
if strings.Contains(err.Error(), "does not exist") {
return fmt.Errorf(
"upstream branch %s/%s not found.\nIf you expect it to exist, you should fetch (with 'f').\nOtherwise, you should push (with 'shift+P')",
upstreamRemote, upstreamBranch,
)
}
return err
}
return nil
}
type PullFilesOptions struct {
UpstreamRemote string
UpstreamBranch string
FastForwardOnly bool
Action string
}
func (self *SyncController) PullAux(currentBranch *models.Branch, opts PullFilesOptions) error {
return self.c.WithInlineStatus(currentBranch, types.ItemOperationPulling, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
return self.pullWithLock(task, opts)
})
}
func (self *SyncController) pullWithLock(task gocui.Task, opts PullFilesOptions) error {
self.c.LogAction(opts.Action)
err := self.c.Git().Sync.Pull(
task,
git_commands.PullOptions{
RemoteName: opts.UpstreamRemote,
BranchName: opts.UpstreamBranch,
FastForwardOnly: opts.FastForwardOnly,
},
)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseAndSelectHeadCommit(err)
}
type pushOpts struct {
force bool
forceWithLease bool
upstreamRemote string
upstreamBranch string
setUpstream bool
// If this is false, we can't tell ahead of time whether a force-push will
// be necessary, so we start with a normal push and offer to force-push if
// the server rejected. If this is true, we don't offer to force-push if the
// server rejected, but rather ask the user to fetch.
remoteBranchStoredLocally bool
// Branches stacked below the current one, pushed after it with one command
// per remote, with --force-with-lease if forceWithLeaseBelow is set. The
// force options above apply to the current branch's own push only.
branchesBelow []*models.Branch
forceWithLeaseBelow bool
}
func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts) error {
return self.withPushingStatus(currentBranch, opts.branchesBelow, func(task gocui.Task) error {
refspecs := []string{}
if opts.upstreamBranch != "" {
refspecs = append(refspecs, fmt.Sprintf("refs/heads/%s:%s", currentBranch.Name, opts.upstreamBranch))
}
err := self.c.Git().Sync.Push(
task,
git_commands.PushOpts{
Force: opts.force,
ForceWithLease: opts.forceWithLease,
SetUpstream: opts.setUpstream,
Remote: opts.upstreamRemote,
Refspecs: refspecs,
})
if err != nil {
if !opts.force && !opts.forceWithLease && strings.Contains(err.Error(), "Updates were rejected") {
if opts.remoteBranchStoredLocally {
return errors.New(self.c.Tr.UpdatesRejected)
}
forcePushDisabled := self.c.UserConfig().Git.DisableForcePushing
if forcePushDisabled {
return errors.New(self.c.Tr.UpdatesRejectedAndForcePushDisabled)
}
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.ForcePush,
Prompt: self.forcePushPrompt(),
HandleConfirm: func() error {
newOpts := opts
newOpts.force = true
return self.pushAux(currentBranch, newOpts)
},
})
return nil
}
return err
}
err = self.pushBranches(task, opts.branchesBelow, opts.forceWithLeaseBelow)
self.c.RefreshFromWorker(types.RefreshOptions{})
return err
})
}
// Pushes the current branch along with the branches stacked below it, all of
// them with explicit refspecs
func (self *SyncController) pushBranchesAux(currentBranch *models.Branch, branchesBelow []*models.Branch, forceWithLease bool) error {
return self.withPushingStatus(currentBranch, branchesBelow, func(task gocui.Task) error {
branches := append([]*models.Branch{currentBranch}, branchesBelow...)
err := self.pushBranches(task, branches, forceWithLease)
self.c.RefreshFromWorker(types.RefreshOptions{})
return err
})
}
// Runs f as a push of the current branch, showing it and the other branches
// as being pushed while it runs
func (self *SyncController) withPushingStatus(currentBranch *models.Branch, otherBranches []*models.Branch, f func(gocui.Task) error) error {
return self.c.WithInlineStatus(currentBranch, types.ItemOperationPushing, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
for _, branch := range otherBranches {
self.c.State().SetItemOperation(branch, types.ItemOperationPushing)
}
defer func() {
for _, branch := range otherBranches {
self.c.State().ClearItemOperation(branch)
}
}()
self.c.LogAction(self.c.Tr.Actions.Push)
return f(task)
})
}
// Pushes the branches to their push destinations, one command per remote
func (self *SyncController) pushBranches(task gocui.Task, branches []*models.Branch, forceWithLease bool) error {
remotes := lo.Uniq(lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.PushRemote }))
for _, remote := range remotes {
refspecs := lo.FilterMap(branches, func(branch *models.Branch, _ int) (string, bool) {
return fmt.Sprintf("refs/heads/%s:refs/heads/%s", branch.Name, branch.PushBranch), branch.PushRemote == remote
})
err := self.c.Git().Sync.Push(task, git_commands.PushOpts{
ForceWithLease: forceWithLease,
Remote: remote,
Refspecs: refspecs,
})
if err != nil {
return err
}
}
return nil
}
func (self *SyncController) requestToForcePush(currentBranch *models.Branch, opts pushOpts) error {
forcePushDisabled := self.c.UserConfig().Git.DisableForcePushing
if forcePushDisabled {
return errors.New(self.c.Tr.ForcePushDisabled)
}
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.ForcePush,
Prompt: self.forcePushPrompt(),
HandleConfirm: func() error {
opts.forceWithLease = true
return self.pushAux(currentBranch, opts)
},
})
return nil
}
func (self *SyncController) forcePushPrompt() string {
return utils.ResolvePlaceholderString(
self.c.Tr.ForcePushPrompt,
map[string]string{
"cancelKey": self.c.UserConfig().Keybinding.Universal.Return.String(),
"confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm.String(),
},
)
}