Push all branches of a rebased branch stack at once (#6045)

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 (i.e. what its
upstream branch's name is or on which remote it is stored); the branches
panel doesn't show that for a normal push either. If any of the branches
has diverged from its remote branch, a single confirmation covers
force-pushing all of them (listing exactly the ones that need
force-pushing).

This only covers branches that already have an upstream configured,
because a branch that was never pushed yet
can't be told apart from one that is meant to stay local. This means
that the very first time you want to push a newly created stack, you
still need to do it manually the old way. We can see if we want to
improve this somehow in the future, but repeatedly pushing a rebased
stack is the more frequent operation, and that's what we improve here.

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.

Some repositories are configured to reject pushes of more than x
branches at once; for those, the command fails with an error. We could
be smarter about this, detect the error, and fall back to pushing each
branch one by one, but I first want to see how many reports we get about
this before investing in the extra logic.
This commit is contained in:
Stefan Haller
2026-09-27 11:00:32 +02:00
committed by GitHub
21 changed files with 850 additions and 49 deletions
+8
View File
@@ -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.
+1 -1
View File
@@ -10,7 +10,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Scroll up main window | |
| `` <pgdown>, J, <ctrl+d> (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.<br><br>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.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
+1 -1
View File
@@ -10,7 +10,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 메인 패널을 위로 스크롤 | |
| `` <pgdown>, J, <ctrl+d> (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.<br><br>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.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
+1 -1
View File
@@ -10,7 +10,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Прокрутить вверх главную панель | |
| `` <pgdown>, J, <ctrl+d> (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.<br><br>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.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
+27 -3
View File
@@ -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)
@@ -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",
+10 -9
View File
@@ -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/<local branch>:<remote ref>".
// If empty, git decides what to push based on push.default and
// remote.<name>.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)
+22 -13
View File
@@ -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) {
+14
View File
@@ -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.<name>.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
@@ -119,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 != ""
@@ -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
}
@@ -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)
})
}
}
+195 -11
View File
@@ -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,18 +90,140 @@ 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()}
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 self.pushAux(currentBranch, pushOpts{setUpstream: true})
return onResolved(pushOpts{setUpstream: true})
}
return self.c.Helpers().Upstream.PromptForUpstreamWithInitialContent(currentBranch, func(upstream string) error {
@@ -107,7 +232,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,
@@ -190,20 +315,28 @@ 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))
}
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") {
@@ -229,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 {
+11 -1
View File
@@ -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",
@@ -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"),
)
},
})
@@ -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 '<remote> <branchname>'")).
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 ✓"),
)
},
})
@@ -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 ✓"),
)
},
})
@@ -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 <esc> to cancel, or <enter> to force push.")).
Confirm()
t.Views().Branches().
Lines(
Contains("branch3 ✓"),
Contains("branch1 ↓1↑1"),
Contains("branch2 ↓2↑2"),
Contains("local-only"),
Contains("master ✓"),
)
},
})
+32
View File
@@ -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"))
+4
View File
@@ -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,
+62 -1
View File
@@ -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
+42 -2
View File
@@ -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())
})
}
}