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/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/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/commands/models/branch.go b/pkg/commands/models/branch.go index 4dc48a88d..29b8fccf0 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 @@ -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 != "" 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) + }) + } +} diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index 61b92747b..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,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 { 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, 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()) }) } }