From fa427c32b3bb1fbe0a0c14a67d8bd6445ceb4f4d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 20 Sep 2026 16:38:29 +0200 Subject: [PATCH 1/6] Store each branch's push destination in the branch model To push a branch other than the checked-out one, lazygit has to name the remote and the remote branch in the push command; a bare `git push` only works for the current branch. The upstream isn't always the right destination. In a triangular workflow the branch is pushed to a different remote than it pulls from, and with push.default set to "current" it goes to a branch of the same name whatever the upstream is called. Read the destination from git instead of working it out from the config. The %(push) field of for-each-ref names the remote-tracking ref that a push would update, taking push.default, remote.pushDefault and branch..pushRemote into account. It is also the ref that the push:track counts are computed against, so the force-push detection and the destination agree. The field is available since git 2.5, well before the oldest version lazygit supports. Nothing uses the new fields yet. Co-Authored-By: Claude Fable 5.1 --- pkg/commands/git_commands/branch_loader.go | 30 ++++++++++-- .../git_commands/branch_loader_test.go | 48 ++++++++++++++++--- pkg/commands/models/branch.go | 7 +++ 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index 0ae10be37..e4ccc6ab7 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -349,6 +349,7 @@ var branchFields = []string{ "upstream:short", "upstream:track", "push:track", + "push", "subject", "objectname", "committerdate:unix", @@ -361,13 +362,15 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch upstreamName := split[2] track := split[3] pushTrack := split[4] - subject := split[5] - commitHash := split[6] - commitDate := split[7] + pushRef := split[5] + subject := split[6] + commitHash := split[7] + commitDate := split[8] name := strings.TrimPrefix(fullName, "heads/") aheadForPull, behindForPull, gone := parseUpstreamInfo(upstreamName, track) aheadForPush, behindForPush, _ := parseUpstreamInfo(upstreamName, pushTrack) + pushRemote, pushBranch := parsePushDestination(pushRef) recency := "" if storeCommitDateAsRecency { @@ -383,6 +386,8 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch BehindForPull: behindForPull, AheadForPush: aheadForPush, BehindForPush: behindForPush, + PushRemote: pushRemote, + PushBranch: pushBranch, UpstreamGone: gone, Head: headMarker == "*", Subject: subject, @@ -410,6 +415,25 @@ func parseUpstreamInfo(upstreamName string, track string) (string, string, bool) return ahead, behind, false } +// Splits the remote-tracking ref that the %(push) field names, e.g. +// refs/remotes/origin/main, into the remote and the remote branch. Returns +// empty strings if the field is empty because git has no push destination for +// the branch, or if the ref isn't under refs/remotes/. +func parsePushDestination(pushRef string) (string, string) { + remoteAndBranch, ok := strings.CutPrefix(pushRef, "refs/remotes/") + if !ok { + return "", "" + } + + // Remote names can't contain slashes, so the first one ends the remote name + remote, branch, ok := strings.Cut(remoteAndBranch, "/") + if !ok { + return "", "" + } + + return remote, branch +} + func parseDifference(track string, regexStr string) string { re := regexp.MustCompile(regexStr) match := re.FindStringSubmatch(track) diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go index 3c1402a24..080c5e2f7 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -26,7 +26,7 @@ func TestObtainBranch(t *testing.T) { scenarios := []scenario{ { testName: "TrimHeads", - input: []string{"", "heads/a_branch", "", "", "", "subject", "123", timeStamp}, + input: []string{"", "heads/a_branch", "", "", "", "", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", @@ -41,7 +41,7 @@ func TestObtainBranch(t *testing.T) { }, { testName: "NoUpstream", - input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp}, + input: []string{"", "a_branch", "", "", "", "", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", @@ -56,7 +56,7 @@ func TestObtainBranch(t *testing.T) { }, { testName: "IsHead", - input: []string{"*", "a_branch", "", "", "", "subject", "123", timeStamp}, + input: []string{"*", "a_branch", "", "", "", "", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", @@ -71,7 +71,7 @@ func TestObtainBranch(t *testing.T) { }, { testName: "IsBehindAndAhead", - input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "subject", "123", timeStamp}, + input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "refs/remotes/a_remote/a_branch", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", @@ -79,6 +79,40 @@ func TestObtainBranch(t *testing.T) { BehindForPull: "2", AheadForPush: "3", BehindForPush: "2", + PushRemote: "a_remote", + PushBranch: "a_branch", + Head: false, + Subject: "subject", + CommitHash: "123", + }, + }, + { + testName: "PushDestinationDiffersFromUpstream", + input: []string{"", "a_branch", "a_remote/a_branch", "[ahead 3]", "[ahead 5]", "refs/remotes/my_fork/feature/a_branch", "subject", "123", timeStamp}, + storeCommitDateAsRecency: false, + expectedBranch: &models.Branch{ + Name: "a_branch", + AheadForPull: "3", + BehindForPull: "0", + AheadForPush: "5", + BehindForPush: "0", + PushRemote: "my_fork", + PushBranch: "feature/a_branch", + Head: false, + Subject: "subject", + CommitHash: "123", + }, + }, + { + testName: "PushDestinationNotARemoteTrackingRef", + input: []string{"", "a_branch", "a_remote/a_branch", "", "", "refs/published/a_branch", "subject", "123", timeStamp}, + storeCommitDateAsRecency: false, + expectedBranch: &models.Branch{ + Name: "a_branch", + AheadForPull: "0", + BehindForPull: "0", + AheadForPush: "0", + BehindForPush: "0", Head: false, Subject: "subject", CommitHash: "123", @@ -86,7 +120,7 @@ func TestObtainBranch(t *testing.T) { }, { testName: "RemoteBranchIsGone", - input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "subject", "123", timeStamp}, + input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "refs/remotes/a_remote/a_branch", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", @@ -95,6 +129,8 @@ func TestObtainBranch(t *testing.T) { BehindForPull: "?", AheadForPush: "?", BehindForPush: "?", + PushRemote: "a_remote", + PushBranch: "a_branch", Head: false, Subject: "subject", CommitHash: "123", @@ -102,7 +138,7 @@ func TestObtainBranch(t *testing.T) { }, { testName: "WithCommitDateAsRecency", - input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp}, + input: []string{"", "a_branch", "", "", "", "", "subject", "123", timeStamp}, storeCommitDateAsRecency: true, expectedBranch: &models.Branch{ Name: "a_branch", diff --git a/pkg/commands/models/branch.go b/pkg/commands/models/branch.go index 4dc48a88d..37a30deb7 100644 --- a/pkg/commands/models/branch.go +++ b/pkg/commands/models/branch.go @@ -31,6 +31,13 @@ type Branch struct { // 'git@github.com:tiwood/lazygit.git' UpstreamRemote string UpstreamBranch string + // The remote and the remote branch that `git push` would push this branch + // to, as git determines them from push.default, remote.pushDefault and + // branch..pushRemote. In a triangular workflow these differ from the + // upstream. Both are empty if git has no push destination for the branch, + // e.g. because push.default is "upstream" and the branch has no upstream. + PushRemote string + PushBranch string // subject line in commit message Subject string // commit hash From 6378618544fb10d4890e2db4ebc4f9236e6287d4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 20 Sep 2026 16:40:32 +0200 Subject: [PATCH 2/6] Generalize the push command to take a remote and a list of refspecs PushOpts can only describe a push of the current branch. It takes the branch's name and the upstream to push it to, and builds a single refspec from them. Pushing several branches in one command needs one refspec per branch, so let callers pass the refspecs themselves. The sync controller, the only caller so far, builds the same single refspec that PushCmdObj built before. Co-Authored-By: Claude Fable 5.1 --- pkg/commands/git_commands/sync.go | 19 +++++++------- pkg/commands/git_commands/sync_test.go | 35 ++++++++++++++++---------- pkg/gui/controllers/sync_controller.go | 9 ++++--- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/pkg/commands/git_commands/sync.go b/pkg/commands/git_commands/sync.go index 0400b4e26..1a20a3293 100644 --- a/pkg/commands/git_commands/sync.go +++ b/pkg/commands/git_commands/sync.go @@ -1,8 +1,6 @@ package git_commands import ( - "fmt" - "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/gocui" @@ -18,18 +16,21 @@ func NewSyncCommands(gitCommon *GitCommon) *SyncCommands { } } -// Push pushes to a branch type PushOpts struct { Force bool ForceWithLease bool - CurrentBranch string - UpstreamRemote string - UpstreamBranch string SetUpstream bool + // The remote to push to. If empty, git picks it from its configuration, + // and Refspecs must be empty too. + Remote string + // What to push, each in the form "refs/heads/:". + // If empty, git decides what to push based on push.default and + // remote..push. + Refspecs []string } func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (*oscommands.CmdObj, error) { - if opts.UpstreamBranch != "" && opts.UpstreamRemote == "" { + if len(opts.Refspecs) > 0 && opts.Remote == "" { return nil, errors.New(self.Tr.MustSpecifyOriginError) } @@ -37,8 +38,8 @@ func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (*oscommand ArgIf(opts.Force, "--force"). ArgIf(opts.ForceWithLease, "--force-with-lease"). ArgIf(opts.SetUpstream, "--set-upstream"). - ArgIf(opts.UpstreamRemote != "", opts.UpstreamRemote). - ArgIf(opts.UpstreamBranch != "", fmt.Sprintf("refs/heads/%s:%s", opts.CurrentBranch, opts.UpstreamBranch)). + ArgIf(opts.Remote != "", opts.Remote). + Arg(opts.Refspecs...). ToArgv() cmdObj := self.cmd.New(cmdArgs).PromptOnCredentialRequest(task) diff --git a/pkg/commands/git_commands/sync_test.go b/pkg/commands/git_commands/sync_test.go index 6a7702586..82dcf2801 100644 --- a/pkg/commands/git_commands/sync_test.go +++ b/pkg/commands/git_commands/sync_test.go @@ -41,12 +41,11 @@ func TestSyncPush(t *testing.T) { }, }, { - testName: "Push with force disabled, upstream supplied", + testName: "Push with force disabled, refspec supplied", opts: PushOpts{ ForceWithLease: false, - CurrentBranch: "master", - UpstreamRemote: "origin", - UpstreamBranch: "master", + Remote: "origin", + Refspecs: []string{"refs/heads/master:master"}, }, test: func(cmdObj *oscommands.CmdObj, err error) { assert.Equal(t, cmdObj.Args(), []string{"git", "push", "origin", "refs/heads/master:master"}) @@ -57,9 +56,8 @@ func TestSyncPush(t *testing.T) { testName: "Push with force disabled, setting upstream", opts: PushOpts{ ForceWithLease: false, - CurrentBranch: "master-local", - UpstreamRemote: "origin", - UpstreamBranch: "master", + Remote: "origin", + Refspecs: []string{"refs/heads/master-local:master"}, SetUpstream: true, }, test: func(cmdObj *oscommands.CmdObj, err error) { @@ -71,9 +69,8 @@ func TestSyncPush(t *testing.T) { testName: "Push with force-with-lease enabled, setting upstream", opts: PushOpts{ ForceWithLease: true, - CurrentBranch: "master", - UpstreamRemote: "origin", - UpstreamBranch: "master", + Remote: "origin", + Refspecs: []string{"refs/heads/master:master"}, SetUpstream: true, }, test: func(cmdObj *oscommands.CmdObj, err error) { @@ -82,11 +79,23 @@ func TestSyncPush(t *testing.T) { }, }, { - testName: "Push with remote branch but no origin", + testName: "Push several refspecs", opts: PushOpts{ ForceWithLease: true, - UpstreamRemote: "", - UpstreamBranch: "master", + Remote: "origin", + Refspecs: []string{"refs/heads/a:refs/heads/a", "refs/heads/b:refs/heads/b"}, + }, + test: func(cmdObj *oscommands.CmdObj, err error) { + assert.Equal(t, cmdObj.Args(), []string{"git", "push", "--force-with-lease", "origin", "refs/heads/a:refs/heads/a", "refs/heads/b:refs/heads/b"}) + assert.NoError(t, err) + }, + }, + { + testName: "Push with refspec but no remote", + opts: PushOpts{ + ForceWithLease: true, + Remote: "", + Refspecs: []string{"refs/heads/master:master"}, SetUpstream: true, }, test: func(cmdObj *oscommands.CmdObj, err error) { diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index 61b92747b..d787a54a8 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -195,15 +195,18 @@ type pushOpts struct { func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts) error { return self.c.WithInlineStatus(currentBranch, types.ItemOperationPushing, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.Push) + 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, - CurrentBranch: currentBranch.Name, - UpstreamRemote: opts.upstreamRemote, - UpstreamBranch: opts.upstreamBranch, SetUpstream: opts.setUpstream, + Remote: opts.upstreamRemote, + Refspecs: refspecs, }) if err != nil { if !opts.force && !opts.forceWithLease && strings.Contains(err.Error(), "Updates were rejected") { From c3b09cebad1f3468d0f7f14aab9f8bbcd583544f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 20 Sep 2026 16:48:34 +0200 Subject: [PATCH 3/6] Add a function to find the branches stacked below a branch lazygit will offer to push these along with the current branch, so that a rebased stack can be pushed in one go. The commits panel already marks the tips of stacked branches, and this uses the same criterion. A branch is below the given one if its tip is one of the loaded commits of that branch that isn't merged into a main branch. This needs no git command. In whole-graph mode the commit list also contains commits of other branches, but those are marked as merged, so they don't count. Not used yet. Co-Authored-By: Claude Fable 5.1 --- pkg/gui/controllers/helpers/branch_stack.go | 39 +++++++ .../controllers/helpers/branch_stack_test.go | 108 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 pkg/gui/controllers/helpers/branch_stack.go create mode 100644 pkg/gui/controllers/helpers/branch_stack_test.go diff --git a/pkg/gui/controllers/helpers/branch_stack.go b/pkg/gui/controllers/helpers/branch_stack.go new file mode 100644 index 000000000..9d7ba4816 --- /dev/null +++ b/pkg/gui/controllers/helpers/branch_stack.go @@ -0,0 +1,39 @@ +package helpers + +import ( + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/samber/lo" +) + +// Returns the branches stacked below the given branch: those whose tip is one +// of the given commits of the branch that hasn't been merged into a main +// branch yet. The branch itself and the main branches are left out. The +// result is in the order in which the tips appear in commits, so the branch +// closest to the given one comes first. +// +// Only the commits passed in are looked at, so a branch whose tip is further +// down the history than the loaded commits is not found. +func BranchesBelowInStack( + commits []*models.Commit, + branches []*models.Branch, + branch *models.Branch, + mainBranches []string, +) []*models.Branch { + branchesByTip := map[string][]*models.Branch{} + for _, other := range branches { + if other.Name == branch.Name || lo.Contains(mainBranches, other.Name) { + continue + } + branchesByTip[other.CommitHash] = append(branchesByTip[other.CommitHash], other) + } + + result := []*models.Branch{} + for _, commit := range commits { + if commit.IsTODO() || commit.Status == models.StatusMerged { + continue + } + result = append(result, branchesByTip[commit.Hash()]...) + } + + return result +} diff --git a/pkg/gui/controllers/helpers/branch_stack_test.go b/pkg/gui/controllers/helpers/branch_stack_test.go new file mode 100644 index 000000000..5fb94d81c --- /dev/null +++ b/pkg/gui/controllers/helpers/branch_stack_test.go @@ -0,0 +1,108 @@ +package helpers + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" + "github.com/stefanhaller/git-todo-parser/todo" + "github.com/stretchr/testify/assert" +) + +func TestBranchesBelowInStack(t *testing.T) { + hashPool := &utils.StringPool{} + commit := func(hash string, status models.CommitStatus) *models.Commit { + return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Status: status}) + } + todoCommit := func(hash string) *models.Commit { + return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Action: todo.Pick, Status: models.StatusRebasing}) + } + branch := func(name string, tip string) *models.Branch { + return &models.Branch{Name: name, CommitHash: tip} + } + + current := branch("current", "c3") + mainBranches := []string{"master", "main"} + + scenarios := []struct { + testName string + commits []*models.Commit + branches []*models.Branch + expected []string + }{ + { + testName: "stack of branches, closest to the current one first", + commits: []*models.Commit{ + commit("c3", models.StatusUnpushed), + commit("c2", models.StatusPushed), + commit("c1", models.StatusUnpushed), + commit("m1", models.StatusMerged), + }, + branches: []*models.Branch{current, branch("first", "c1"), branch("second", "c2"), branch("master", "m1")}, + expected: []string{"second", "first"}, + }, + { + testName: "branch pointing at the same commit as the current one", + commits: []*models.Commit{ + commit("c3", models.StatusUnpushed), + commit("m1", models.StatusMerged), + }, + branches: []*models.Branch{current, branch("twin", "c3")}, + expected: []string{"twin"}, + }, + { + testName: "several branches pointing at one commit keep their order", + commits: []*models.Commit{ + commit("c3", models.StatusUnpushed), + commit("c1", models.StatusUnpushed), + }, + branches: []*models.Branch{current, branch("one", "c1"), branch("two", "c1")}, + expected: []string{"one", "two"}, + }, + { + testName: "main branches are left out even if their tip isn't merged", + commits: []*models.Commit{ + commit("c3", models.StatusUnpushed), + commit("c1", models.StatusUnpushed), + }, + branches: []*models.Branch{current, branch("main", "c1")}, + expected: []string{}, + }, + { + testName: "branches whose tip is merged are left out", + commits: []*models.Commit{ + commit("c3", models.StatusUnpushed), + commit("m1", models.StatusMerged), + commit("m0", models.StatusMerged), + }, + branches: []*models.Branch{current, branch("old", "m0"), branch("master", "m1")}, + expected: []string{}, + }, + { + testName: "branches whose tip is not among the commits are left out", + commits: []*models.Commit{ + commit("c3", models.StatusUnpushed), + }, + branches: []*models.Branch{current, branch("sibling", "s1")}, + expected: []string{}, + }, + { + testName: "todo commits of a rebase don't count", + commits: []*models.Commit{ + todoCommit("t1"), + commit("c3", models.StatusUnpushed), + }, + branches: []*models.Branch{current, branch("other", "t1")}, + expected: []string{}, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + result := BranchesBelowInStack(s.commits, s.branches, current, mainBranches) + names := lo.Map(result, func(b *models.Branch, _ int) string { return b.Name }) + assert.Equal(t, s.expected, names) + }) + } +} From bfb8c91b7b91aa708bfa7d3e4aa71792a74e6ff5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 20 Sep 2026 17:05:35 +0200 Subject: [PATCH 4/6] Split pushing the current branch into resolving its destination and pushing Pushing the current branch has three cases: it has an upstream, it has none but push.default is "current", or the user is prompted for one. Each case ends in a push, and the first one also checks whether a force push is needed. Let the three cases call a common callback instead, and put the check and the push there. This makes it possible to run the same three cases with a different callback, so that the branches stacked below the current one can be pushed along with it. The check returns false for a branch without an upstream, so the other two cases behave as before. Co-Authored-By: Claude Fable 5.1 --- pkg/gui/controllers/sync_controller.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index d787a54a8..08ffc3051 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -87,18 +87,26 @@ func (self *SyncController) branchCheckedOut(f func(*models.Branch) error) func( } func (self *SyncController) push(currentBranch *models.Branch) error { - // if we are behind our upstream branch we'll ask if the user wants to force push - if currentBranch.IsTrackingRemote() { - opts := pushOpts{remoteBranchStoredLocally: currentBranch.RemoteBranchStoredLocally()} + 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) + }) +} + +// 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 self.pushAux(currentBranch, pushOpts{setUpstream: true}) + return onResolved(pushOpts{setUpstream: true}) } return self.c.Helpers().Upstream.PromptForUpstreamWithInitialContent(currentBranch, func(upstream string) error { @@ -107,7 +115,7 @@ func (self *SyncController) push(currentBranch *models.Branch) error { return err } - return self.pushAux(currentBranch, pushOpts{ + return onResolved(pushOpts{ setUpstream: true, upstreamRemote: upstreamRemote, upstreamBranch: upstreamBranch, From 9a585bf11e0d9198ed65e81b5d56f10f2de8876c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 20 Sep 2026 17:25:22 +0200 Subject: [PATCH 5/6] 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..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..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 --- docs-master/Stacked_Branches.md | 8 + docs-master/keybindings/Keybindings_en.md | 2 +- docs-master/keybindings/Keybindings_ko.md | 2 +- docs-master/keybindings/Keybindings_ru.md | 2 +- pkg/commands/models/branch.go | 7 + pkg/gui/controllers/sync_controller.go | 179 +++++++++++++++++- pkg/i18n/english.go | 12 +- .../tests/sync/push_stacked_branches.go | 73 +++++++ ...acked_branches_current_without_upstream.go | 54 ++++++ .../sync/push_stacked_branches_no_force.go | 64 +++++++ .../push_stacked_branches_only_current.go | 40 ++++ pkg/integration/tests/sync/shared.go | 32 ++++ pkg/integration/tests/test_list.go | 4 + 13 files changed, 472 insertions(+), 7 deletions(-) create mode 100644 pkg/integration/tests/sync/push_stacked_branches.go create mode 100644 pkg/integration/tests/sync/push_stacked_branches_current_without_upstream.go create mode 100644 pkg/integration/tests/sync/push_stacked_branches_no_force.go create mode 100644 pkg/integration/tests/sync/push_stacked_branches_only_current.go diff --git a/docs-master/Stacked_Branches.md b/docs-master/Stacked_Branches.md index cd573be26..84a11507e 100644 --- a/docs-master/Stacked_Branches.md +++ b/docs-master/Stacked_Branches.md @@ -16,3 +16,11 @@ branches properly stacked onto it. Lazygit visualizes the individual branch heads in the stack by marking them with a cyan asterisk (or a cyan branch symbol if you are using [nerd fonts](Config.md#display-nerd-fonts-icons)). + +When you push the topmost branch of the stack with `P`, and the branches below +it have commits that haven't been pushed yet, lazygit offers to push them along +with it. After rebasing the stack this saves you from checking out and +force-pushing every branch one by one; you are asked to confirm the force push +once for all of them. Only branches that already have an upstream are included. +Each of them is pushed to where `git push` would push it if it were checked out, +so your push configuration applies to them as usual. diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 3ec731bf2..835a353c5 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -10,7 +10,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , K, (fn+up/shift+k) `` | Scroll up main window | | | `` , J, (fn+down/shift+j) `` | Scroll down main window | | | `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. | -| `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | +| `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. | | `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.

The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.

The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index a0e5d84dc..653024df5 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -10,7 +10,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , K, (fn+up/shift+k) `` | 메인 패널을 위로 스크롤 | | | `` , J, (fn+down/shift+j) `` | 메인 패널을 아래로로 스크롤 | | | `` @ `` | 명령어 로그 메뉴 열기 | View options for the command log e.g. show/hide the command log and focus the command log. | -| `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | +| `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. | | `` p `` | 업데이트 | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.

The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.

The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 3d03f9ca6..379429163 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -10,7 +10,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , K, (fn+up/shift+k) `` | Прокрутить вверх главную панель | | | `` , J, (fn+down/shift+j) `` | Прокрутить вниз главную панель | | | `` @ `` | Открыть меню журнала команд | View options for the command log e.g. show/hide the command log and focus the command log. | -| `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | +| `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. | | `` p `` | Получить и слить изменения | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.

The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.

The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. | diff --git a/pkg/commands/models/branch.go b/pkg/commands/models/branch.go index 37a30deb7..29b8fccf0 100644 --- a/pkg/commands/models/branch.go +++ b/pkg/commands/models/branch.go @@ -126,6 +126,13 @@ func (b *Branch) IsBehindForPush() bool { return b.RemoteBranchStoredLocally() && b.BehindForPush != "0" } +// Whether the branch has commits that its push destination doesn't have. False +// if the remote branch it would be pushed to isn't stored locally, in which +// case the count is "?". +func (b *Branch) IsAheadForPush() bool { + return b.RemoteBranchStoredLocally() && b.AheadForPush != "0" && b.AheadForPush != "?" +} + // for when we're in a detached head state func (b *Branch) IsRealBranch() bool { return b.AheadForPull != "" && b.BehindForPull != "" diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index 08ffc3051..15b37b0b1 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -9,8 +9,11 @@ import ( "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 { @@ -87,6 +90,33 @@ func (self *SyncController) branchCheckedOut(f func(*models.Branch) error) func( } 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() { @@ -97,6 +127,93 @@ func (self *SyncController) push(currentBranch *models.Branch) error { }) } +// 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. @@ -198,11 +315,16 @@ type pushOpts struct { // 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.c.WithInlineStatus(currentBranch, types.ItemOperationPushing, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { - self.c.LogAction(self.c.Tr.Actions.Push) + 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)) @@ -240,11 +362,62 @@ func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts) } return err } + + err = self.pushBranches(task, opts.branchesBelow, opts.forceWithLeaseBelow) self.c.RefreshFromWorker(types.RefreshOptions{}) - return nil + 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 { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 7a39cd716..d3847ce32 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -239,6 +239,11 @@ type TranslationSet struct { ForcePush string ForcePushPrompt string ForcePushDisabled string + ForcePushBranchesPrompt string + ForcePushBranchesDisabled string + BranchesBelowHaveCommitsToPush string + PushBranchAndBranchesBelow string + PushOnlyCurrentBranch string UpdatesRejected string UpdatesRejectedAndForcePushDisabled string CheckForUpdate string @@ -1222,7 +1227,7 @@ func EnglishTranslationSet() *TranslationSet { Refresh: "Refresh", RefreshTooltip: "Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`.", Push: "Push", - PushTooltip: "Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch.", + PushTooltip: "Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too.", Pull: "Pull", PullTooltip: "Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch.", MergeConflictsTitle: "Merge conflicts", @@ -1400,6 +1405,11 @@ func EnglishTranslationSet() *TranslationSet { ForcePush: "Force push", ForcePushPrompt: "Your branch has diverged from the remote branch. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.", ForcePushDisabled: "Your branch has diverged from the remote branch and you've disabled force pushing", + ForcePushBranchesPrompt: "The following branches have diverged from their remote branches:\n\n{{.branches}}\n\nPress {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.", + ForcePushBranchesDisabled: "Some of these branches have diverged from their remote branches and you've disabled force pushing", + BranchesBelowHaveCommitsToPush: "The following branches stacked below '{{.branchName}}' also have commits to push:", + PushBranchAndBranchesBelow: "Push all these branches in addition to the current one", + PushOnlyCurrentBranch: "Push only '{{.branchName}}'", UpdatesRejected: "Updates were rejected. Please fetch and examine the remote changes before pushing again.", UpdatesRejectedAndForcePushDisabled: "Updates were rejected and you have disabled force pushing", CheckForUpdate: "Check for update", diff --git a/pkg/integration/tests/sync/push_stacked_branches.go b/pkg/integration/tests/sync/push_stacked_branches.go new file mode 100644 index 000000000..9e408ec11 --- /dev/null +++ b/pkg/integration/tests/sync/push_stacked_branches.go @@ -0,0 +1,73 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PushStackedBranches = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Push a rebased stack of branches in one go, force-pushing all of them after a single confirmation", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical" + }, + SetupRepo: func(shell *Shell) { + createRebasedStackOfBranches(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Lines( + Contains("branch3 ↓3↑3"), + Contains("branch1 ↓1↑1"), + Contains("branch2 ↓2↑2"), + Contains("local-only").DoesNotContain("↑"), + Contains("master ✓"), + ) + + t.Views().Files().IsFocused().Press(keys.Universal.Push) + + t.ExpectPopup().Menu(). + Title(Equals("Push")). + ContainsLines( + Contains(" branch2 ↓2↑2"), + Contains(" branch1 ↓1↑1"), + ). + Select(Contains("Push all these branches in addition to the current one")). + Confirm() + + t.ExpectPopup().Confirmation(). + Title(Equals("Force push")). + Content(Contains("The following branches have diverged from their remote branches:"). + Contains("branch3").Contains("branch2").Contains("branch1")). + Confirm() + + t.Views().Branches(). + Lines( + Contains("branch3 ✓"), + Contains("branch1 ✓"), + Contains("branch2 ✓"), + Contains("local-only").DoesNotContain("✓"), + Contains("master ✓"), + ) + + t.Views().Remotes(). + Focus(). + Lines( + Contains("origin"), + ). + PressEnter() + + t.Views().RemoteBranches(). + IsFocused(). + NavigateToLine(Contains("branch1")). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + Lines( + Contains("one-rebased"), + Contains("base"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/push_stacked_branches_current_without_upstream.go b/pkg/integration/tests/sync/push_stacked_branches_current_without_upstream.go new file mode 100644 index 000000000..f7a30f825 --- /dev/null +++ b/pkg/integration/tests/sync/push_stacked_branches_current_without_upstream.go @@ -0,0 +1,54 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PushStackedBranchesCurrentWithoutUpstream = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Push a rebased stack of branches whose topmost branch has no upstream yet; the prompt for its upstream comes before the force-push confirmation", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical" + }, + SetupRepo: func(shell *Shell) { + createRebasedStackOfBranches(shell) + shell.NewBranch("branch4") + shell.EmptyCommit("four") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files().IsFocused().Press(keys.Universal.Push) + + t.ExpectPopup().Menu(). + Title(Equals("Push")). + ContainsLines( + Contains(" branch3 ↓3↑3"), + Contains(" branch2 ↓2↑2"), + Contains(" branch1 ↓1↑1"), + ). + Select(Contains("Push all these branches in addition to the current one")). + Confirm() + + t.ExpectPopup().Prompt(). + Title(Equals("Enter upstream as ' '")). + InitialText(Equals("origin branch4")). + Confirm() + + t.ExpectPopup().Confirmation(). + Title(Equals("Force push")). + Content(Contains("The following branches have diverged from their remote branches:"). + Contains("branch3").Contains("branch2").Contains("branch1").DoesNotContain("branch4")). + Confirm() + + t.Views().Branches(). + Lines( + Contains("branch4 ✓"), + Contains("branch1 ✓"), + Contains("branch2 ✓"), + Contains("branch3 ✓"), + Contains("local-only"), + Contains("master ✓"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/push_stacked_branches_no_force.go b/pkg/integration/tests/sync/push_stacked_branches_no_force.go new file mode 100644 index 000000000..ba89172e9 --- /dev/null +++ b/pkg/integration/tests/sync/push_stacked_branches_no_force.go @@ -0,0 +1,64 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PushStackedBranchesNoForce = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Push a stack of branches that are only ahead of their remote branches, without being asked to force push", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical" + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("base") + shell.NewBranch("branch1") + shell.NewBranch("branch2") + shell.NewBranch("branch3") + + shell.CloneIntoRemote("origin") + shell.SetBranchUpstream("master", "origin/master") + shell.SetBranchUpstream("branch1", "origin/branch1") + shell.SetBranchUpstream("branch2", "origin/branch2") + shell.SetBranchUpstream("branch3", "origin/branch3") + + shell.Checkout("branch1") + shell.EmptyCommit("one") + shell.Checkout("branch2") + shell.HardReset("branch1") + shell.EmptyCommit("two") + shell.Checkout("branch3") + shell.HardReset("branch2") + shell.EmptyCommit("three") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Lines( + Contains("branch3 ↑3"), + Contains("branch1 ↑1"), + Contains("branch2 ↑2"), + Contains("master ✓"), + ) + + t.Views().Files().IsFocused().Press(keys.Universal.Push) + + t.ExpectPopup().Menu(). + Title(Equals("Push")). + ContainsLines( + Contains(" branch2 ↑2"), + Contains(" branch1 ↑1"), + ). + Select(Contains("Push all these branches in addition to the current one")). + Confirm() + + t.Views().Branches(). + Lines( + Contains("branch3 ✓"), + Contains("branch1 ✓"), + Contains("branch2 ✓"), + Contains("master ✓"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/push_stacked_branches_only_current.go b/pkg/integration/tests/sync/push_stacked_branches_only_current.go new file mode 100644 index 000000000..95a063ec4 --- /dev/null +++ b/pkg/integration/tests/sync/push_stacked_branches_only_current.go @@ -0,0 +1,40 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PushStackedBranchesOnlyCurrent = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Decline pushing the branches below the current one in a rebased stack, pushing only the current branch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical" + }, + SetupRepo: func(shell *Shell) { + createRebasedStackOfBranches(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files().IsFocused().Press(keys.Universal.Push) + + t.ExpectPopup().Menu(). + Title(Equals("Push")). + Select(Contains("Push only 'branch3'")). + Confirm() + + t.ExpectPopup().Confirmation(). + Title(Equals("Force push")). + Content(Equals("Your branch has diverged from the remote branch. Press to cancel, or to force push.")). + Confirm() + + t.Views().Branches(). + Lines( + Contains("branch3 ✓"), + Contains("branch1 ↓1↑1"), + Contains("branch2 ↓2↑2"), + Contains("local-only"), + Contains("master ✓"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/shared.go b/pkg/integration/tests/sync/shared.go index 3e3d5c017..6b1f9065e 100644 --- a/pkg/integration/tests/sync/shared.go +++ b/pkg/integration/tests/sync/shared.go @@ -23,6 +23,38 @@ func createTwoBranchesReadyToForcePush(shell *Shell) { shell.HardReset("HEAD^") } +// Creates the branches branch1, branch2 and branch3, each on top of the +// previous one and pushed to origin, and then rewrites their commits the way +// rebasing the stack would, so that all of them have diverged from their +// remote branches. Also creates local-only at the tip of branch1, a branch +// without an upstream. Leaves branch3 checked out. +func createRebasedStackOfBranches(shell *Shell) { + shell.EmptyCommit("base") + shell.NewBranch("branch1") + shell.EmptyCommit("one") + shell.NewBranch("branch2") + shell.EmptyCommit("two") + shell.NewBranch("branch3") + shell.EmptyCommit("three") + + shell.CloneIntoRemote("origin") + shell.SetBranchUpstream("master", "origin/master") + shell.SetBranchUpstream("branch1", "origin/branch1") + shell.SetBranchUpstream("branch2", "origin/branch2") + shell.SetBranchUpstream("branch3", "origin/branch3") + + shell.Checkout("branch1") + shell.HardReset("master") + shell.EmptyCommit("one-rebased") + shell.NewBranch("local-only") + shell.Checkout("branch2") + shell.HardReset("branch1") + shell.EmptyCommit("two-rebased") + shell.Checkout("branch3") + shell.HardReset("branch2") + shell.EmptyCommit("three-rebased") +} + func assertSuccessfullyPushed(t *TestDriver) { t.Views().Status().Content(Equals("✓ repo → master")) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 96464f469..802993d31 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -499,6 +499,10 @@ var tests = []*components.IntegrationTest{ sync.PushAndSetUpstream, sync.PushFollowTags, sync.PushNoFollowTags, + sync.PushStackedBranches, + sync.PushStackedBranchesCurrentWithoutUpstream, + sync.PushStackedBranchesNoForce, + sync.PushStackedBranchesOnlyCurrent, sync.PushTag, sync.PushWithCredentialPrompt, sync.RenameBranchAndPull, From 149c5f85784298ac67f8f849ab7c0d2b6ef51bd1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 20 Sep 2026 18:22:20 +0200 Subject: [PATCH 6/6] Ignore escape sequences when wrapping text for a view WrapViewLinesToWidth counted the characters of ANSI escape sequences as if they were visible, so colored text wrapped earlier than the view does, and the number of lines it reported was too high. In a narrow terminal the divergence in the menu that offers to push a stack of branches, colored yellow, ended up on a line of its own even though it fitted. The tooltip of a disabled menu item, whose prefix is red, gets its height from the same function. Skip escape sequences when measuring the width, and never break a line inside one. gocui wraps parsed cells and never sees escape sequences, so this brings the two in line; the test's parity check now compares the wrapped lines with the escape sequences stripped. Only CSI sequences are recognized. Those are the color and style codes that lazygit puts into view content; hyperlinks (OSC 8) only occur in the main view, and gocui wraps that one itself. Co-Authored-By: Claude Fable 5.1 --- pkg/utils/lines.go | 63 ++++++++++++++++++++++++++++++++++++++++- pkg/utils/lines_test.go | 44 ++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/pkg/utils/lines.go b/pkg/utils/lines.go index 76c73522c..598dede7f 100644 --- a/pkg/utils/lines.go +++ b/pkg/utils/lines.go @@ -103,11 +103,67 @@ func ScanLinesAndTruncateWhenLongerThanBuffer(maxBufferSize int) func(data []byt } } +type escapeSequenceState int + +const ( + notInEscapeSequence escapeSequenceState = iota + afterEscapeChar + inCSISequence +) + +// Recognizes ANSI escape sequences while iterating over the characters of a +// string. Only CSI sequences (ESC [ ... final byte) are recognized; these are +// the color and style codes that lazygit puts into view content. +type escapeSequenceTracker struct { + state escapeSequenceState +} + +// Feeds the next character to the tracker. Returns true if the character is +// part of an escape sequence, and so takes up no space on screen. +func (self *escapeSequenceTracker) consume(chr rune) bool { + switch self.state { + case afterEscapeChar: + if chr == '[' { + self.state = inCSISequence + } else { + self.state = notInEscapeSequence + } + return true + case inCSISequence: + // Parameter and intermediate bytes are in the range 0x20-0x3F, the + // final byte is in the range 0x40-0x7E + if chr >= 0x40 && chr <= 0x7e { + self.state = notInEscapeSequence + } + return true + default: + if chr == '\x1b' { + self.state = afterEscapeChar + return true + } + return false + } +} + +// The width that the string takes up on screen, leaving out escape sequences +func widthIgnoringEscapeSequences(s string) int { + tracker := escapeSequenceTracker{} + width := 0 + for _, chr := range s { + if !tracker.consume(chr) { + width += uniseg.StringWidth(string(chr)) + } + } + return width +} + // Wrap lines to a given width, and return: // - the wrapped lines // - the line indices of the wrapped lines, indexed by the original line indices // - the line indices of the original lines, indexed by the wrapped line indices // If wrap is false, the text is returned as is. +// Escape sequences stay in the wrapped lines, but don't count towards the +// width, and a line is never broken inside one. // This code needs to behave the same as `gocui.lineWrap` does. func WrapViewLinesToWidth(wrap bool, editable bool, text string, width int, tabWidth int) ([]string, []int, []int) { if !editable { @@ -150,7 +206,12 @@ func WrapViewLinesToWidth(wrap bool, editable bool, text string, width int, tabW n := 0 offset := 0 lastWhitespaceIndex := -1 + escapeSequences := escapeSequenceTracker{} for i, currChr := range line { + if escapeSequences.consume(currChr) { + continue + } + rw := uniseg.StringWidth(string(currChr)) n += rw @@ -170,7 +231,7 @@ func WrapViewLinesToWidth(wrap bool, editable bool, text string, width int, tabW appendWrappedLine(line[offset:lastWhitespaceIndex]) } offset = lastWhitespaceIndex + 1 - n = uniseg.StringWidth(line[offset : i+1]) + n = widthIgnoringEscapeSequences(line[offset : i+1]) } else { appendWrappedLine(line[offset:i]) offset = i diff --git a/pkg/utils/lines_test.go b/pkg/utils/lines_test.go index 2b97761d3..da03f8767 100644 --- a/pkg/utils/lines_test.go +++ b/pkg/utils/lines_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/samber/lo" "github.com/stretchr/testify/assert" ) @@ -420,6 +421,42 @@ func TestWrapViewLinesToWidth(t *testing.T) { expectedWrappedLinesIndices: []int{0, 1, 2, 3}, expectedOriginalLinesIndices: []int{0, 1, 2, 3}, }, + { + name: "Escape sequences don't count towards the width", + wrap: true, + text: "Hello \x1b[33mWorld\x1b[0m", + width: 11, + expectedWrappedLines: []string{ + "Hello \x1b[33mWorld\x1b[0m", + }, + expectedWrappedLinesIndices: []int{0}, + expectedOriginalLinesIndices: []int{0}, + }, + { + name: "Break at a space before a colored word", + wrap: true, + text: "Hello \x1b[33mWorld\x1b[0m", + width: 5, + expectedWrappedLines: []string{ + "Hello", + "\x1b[33mWorld\x1b[0m", + }, + expectedWrappedLinesIndices: []int{0}, + expectedOriginalLinesIndices: []int{0, 0}, + }, + { + name: "Width after a break leaves out escape sequences", + wrap: true, + text: "a \x1b[33mbcd\x1b[0mef", + width: 4, + expectedWrappedLines: []string{ + "a", + "\x1b[33mbcd\x1b[0me", + "f", + }, + expectedWrappedLinesIndices: []int{0}, + expectedOriginalLinesIndices: []int{0, 0, 0}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -436,14 +473,17 @@ func TestWrapViewLinesToWidth(t *testing.T) { assert.Equal(t, tt.expectedOriginalLinesIndices, originalLinesIndices) } - // As a sanity check, also test that gocui's line wrapping behaves the same way + // As a sanity check, also test that gocui's line wrapping behaves the + // same way. gocui turns escape sequences into cell styles, so its + // lines don't contain them. view := gocui.NewView("", 0, 0, tt.width+1, 1000, gocui.OutputNormal) view.TabWidth = tabWidth assert.Equal(t, tt.width, view.InnerWidth()) view.Wrap = tt.wrap view.Editable = tt.editable view.SetContent(tt.text) - assert.Equal(t, wrappedLines, view.ViewBufferLines()) + plainWrappedLines := lo.Map(wrappedLines, func(line string, _ int) string { return Decolorise(line) }) + assert.Equal(t, plainWrappedLines, view.ViewBufferLines()) }) } }