From b7395b16376b3e850ae9f1f8e8b192688c0dda08 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 24 Sep 2026 08:44:53 +0200 Subject: [PATCH 1/3] Split fast-forwarding branches into preparing and running it FastForwardBranches looks up the worktrees of the branches in the model on the UI thread, and then does the rest on a worker, with the branches shown as being fast-forwarded. The next commit needs to update branches as part of pulling the current one, in the pull's own task. Move everything except the inline status into PrepareFastForward, which returns the part to run on a worker. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../controllers/helpers/branches_helper.go | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index c5111f80a..364c07f6d 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -444,8 +444,18 @@ type branchToForward struct { // diverged from it is reset to it, as long as it has no commits of its own. If // any of the branches can't be updated, none of them is. func (self *BranchesHelper) FastForwardBranches(branches []*models.Branch) error { - // The worktrees come from the model, so they have to be looked up here, - // before the work moves to a worker + fastForward, err := self.PrepareFastForward(branches) + if err != nil { + return err + } + + return self.withFastForwardingStatus(branches, fastForward) +} + +// Does the part of FastForwardBranches that looks at the model, and so has to +// run on the UI thread. Returns the rest, which is to be run on a worker; this +// lets other operations include the fast-forward in their own task. +func (self *BranchesHelper) PrepareFastForward(branches []*models.Branch) (func(gocui.Task) error, error) { toForward := lo.Map(branches, func(branch *models.Branch, _ int) *branchToForward { worktree, _ := self.worktreeForBranch(branch) return &branchToForward{branch: branch, worktree: worktree} @@ -456,14 +466,14 @@ func (self *BranchesHelper) FastForwardBranches(branches []*models.Branch) error // rebase or bisect, and leave the branch alone for _, f := range toForward { if f.worktree != nil && f.worktree.IsRebasingOrBisecting { - return errors.New(utils.ResolvePlaceholderString( + return nil, errors.New(utils.ResolvePlaceholderString( self.c.Tr.FwdBranchRebasingOrBisecting, map[string]string{"branchName": f.branch.Name, "worktreeName": f.worktree.Name}, )) } } - return self.withFastForwardingStatus(branches, func(task gocui.Task) error { + return func(task gocui.Task) error { defer func() { if anyCheckedOut { // The files of those worktrees have changed as well @@ -488,7 +498,7 @@ func (self *BranchesHelper) FastForwardBranches(branches []*models.Branch) error } return self.forwardBranches(self.c.Git(), toForward) - }) + }, nil } // Runs f with all the given branches shown as being fast-forwarded From 6919a2aa47af5d7c09387068ec111ab4df78a511 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 24 Sep 2026 08:46:04 +0200 Subject: [PATCH 2/3] Extract showing an inline status on several branches Pushing a stack and fast-forwarding a range of branches each show their operation on all the branches involved, with their own copy of the same code. Pulling a stack will need it too, so move it to BranchesHelper. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/helpers/branches_helper.go | 11 ++++++----- pkg/gui/controllers/sync_controller.go | 12 ++---------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 364c07f6d..3006ba638 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -449,7 +449,7 @@ func (self *BranchesHelper) FastForwardBranches(branches []*models.Branch) error return err } - return self.withFastForwardingStatus(branches, fastForward) + return self.WithInlineStatusOnBranches(branches, types.ItemOperationFastForwarding, fastForward) } // Does the part of FastForwardBranches that looks at the model, and so has to @@ -501,11 +501,12 @@ func (self *BranchesHelper) PrepareFastForward(branches []*models.Branch) (func( }, nil } -// Runs f with all the given branches shown as being fast-forwarded -func (self *BranchesHelper) withFastForwardingStatus(branches []*models.Branch, f func(gocui.Task) error) error { - return self.c.WithInlineStatus(branches[0], types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { +// Runs f on a worker with all the given branches shown as being in the given +// operation while it runs +func (self *BranchesHelper) WithInlineStatusOnBranches(branches []*models.Branch, operation types.ItemOperation, f func(gocui.Task) error) error { + return self.c.WithInlineStatus(branches[0], operation, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { for _, branch := range branches[1:] { - self.c.State().SetItemOperation(branch, types.ItemOperationFastForwarding) + self.c.State().SetItemOperation(branch, operation) } defer func() { for _, branch := range branches[1:] { diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index 15b37b0b1..bd07e2c9a 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -383,16 +383,8 @@ func (self *SyncController) pushBranchesAux(currentBranch *models.Branch, branch // 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) - } - }() - + branches := append([]*models.Branch{currentBranch}, otherBranches...) + return self.c.Helpers().BranchesHelper.WithInlineStatusOnBranches(branches, types.ItemOperationPushing, func(task gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.Push) return f(task) }) From 26ad23a8d2fff80099094f1a391d47141dfaca99 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 24 Sep 2026 08:53:46 +0200 Subject: [PATCH 3/3] Offer to update the branches stacked below the current one when pulling When somebody else rebases a stack and force-pushes it, pulling the topmost branch leaves the branches below it diverged. They can be updated with `f`, but pushing the stack offers to push the branches below along with the current one, and pulling should be symmetric. When pulling, find the branches stacked below the current one that can be updated without losing anything. These are those that are behind their upstream branches, and those that diverged from them only because they were rewritten. If there are any, show a menu that lists them and offers to update them in addition to pulling the current branch, or to pull only the current branch. The current branch is pulled as before. The decision is based on the last fetch, not on a new one, so that the menu appears right away. If the remote has changed since then, a branch might not be offered although it could be updated; it then shows as diverged after the pull, and pulling again offers it. The update itself fetches the upstream branches and checks them again, so it never loses commits. Branches that are checked out in another worktree are not offered, since updating them would change the files there. The branches below are updated before the current branch is pulled. If one of them pointed into the commits that a rebasing pull rebases, the pull would move it when rebase.updateRefs is set, and updating it afterwards would fail. If updating them fails, the current branch isn't pulled. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs-master/Stacked_Branches.md | 8 ++ docs-master/keybindings/Keybindings_en.md | 2 +- docs-master/keybindings/Keybindings_ko.md | 2 +- docs-master/keybindings/Keybindings_ru.md | 2 +- pkg/gui/controllers/sync_controller.go | 98 ++++++++++++++++++- pkg/i18n/english.go | 8 +- .../tests/sync/pull_stacked_branches.go | 56 +++++++++++ ..._branches_checked_out_in_other_worktree.go | 47 +++++++++ .../pull_stacked_branches_only_current.go | 36 +++++++ .../pull_stacked_branches_update_fails.go | 44 +++++++++ ...ull_stacked_branches_with_local_commits.go | 47 +++++++++ pkg/integration/tests/test_list.go | 5 + 12 files changed, 346 insertions(+), 9 deletions(-) create mode 100644 pkg/integration/tests/sync/pull_stacked_branches.go create mode 100644 pkg/integration/tests/sync/pull_stacked_branches_checked_out_in_other_worktree.go create mode 100644 pkg/integration/tests/sync/pull_stacked_branches_only_current.go create mode 100644 pkg/integration/tests/sync/pull_stacked_branches_update_fails.go create mode 100644 pkg/integration/tests/sync/pull_stacked_branches_with_local_commits.go diff --git a/docs-master/Stacked_Branches.md b/docs-master/Stacked_Branches.md index c9b080e11..f961e74fd 100644 --- a/docs-master/Stacked_Branches.md +++ b/docs-master/Stacked_Branches.md @@ -41,3 +41,11 @@ this work. whole stack and bring all of it back in sync at once. If any of the selected branches can't be updated, none of them is, so that you don't end up with half of the stack updated. + +Alternatively, check out the topmost branch of the stack and pull it with `p`. +If branches below it can be updated this way, or are simply behind their +upstream, lazygit offers to update them along with it. Lazygit decides this +from the last fetch, so a branch whose changes on the remote haven't been +fetched yet isn't offered; with auto-fetch turned off, pull a second time after +the first pull has fetched them. Branches that are checked out in another +worktree are left alone. The topmost branch itself is pulled as usual. diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 5010a4f75..f19d74964 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -11,7 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , 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. 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. | +| `` 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. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too. | | `` ) `` | 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'. | | `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view.

The default can be changed in the config file with the key 'git.diffContextSize'. | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 918f15d55..085c8e3fc 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -11,7 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , 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. 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. | +| `` p `` | 업데이트 | Pull changes from the remote for the current 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 changed on the remote, you are offered to update those too. | | `` ) `` | 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 보기의 변경 사항 주위에 표시되는 컨텍스트의 크기를 늘리기 | Increase the amount of the context shown around changes in the diff view.

The default can be changed in the config file with the key 'git.diffContextSize'. | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 716f11f0e..f6304dda9 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -11,7 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , 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. 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. | +| `` p `` | Получить и слить изменения | Pull changes from the remote for the current 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 changed on the remote, you are offered to update those too. | | `` ) `` | 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'. | | `` } `` | Увеличить размер контекста, отображаемого вокруг изменений в просмотрщике сравнении | Increase the amount of the context shown around changes in the diff view.

The default can be changed in the config file with the key 'git.diffContextSize'. | diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index bd07e2c9a..260b5a2fb 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -8,7 +8,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gocui" - "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -241,7 +240,36 @@ func (self *SyncController) resolvePushOfCurrentBranch(currentBranch *models.Bra } func (self *SyncController) pull(currentBranch *models.Branch) error { - action := self.c.Tr.Actions.Pull + branchesBelow := self.updatableBranchesBelow(currentBranch) + if len(branchesBelow) == 0 { + return self.pullCurrentBranch(currentBranch, nil) + } + + branchName := map[string]string{"branchName": currentBranch.Name} + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.Pull, + Prompt: self.branchesBelowToUpdatePrompt(currentBranch, branchesBelow), + Items: []*types.MenuItem{ + { + Label: utils.ResolvePlaceholderString(self.c.Tr.PullBranchAndBranchesBelow, branchName), + OnPress: func() error { + return self.pullCurrentBranch(currentBranch, branchesBelow) + }, + }, + { + Label: utils.ResolvePlaceholderString(self.c.Tr.PullOnlyCurrentBranch, branchName), + OnPress: func() error { + return self.pullCurrentBranch(currentBranch, nil) + }, + }, + }, + }) +} + +// Pulls the current branch, after updating the given branches stacked below it +// to their upstream branches +func (self *SyncController) pullCurrentBranch(currentBranch *models.Branch, branchesBelow []*models.Branch) error { + opts := PullFilesOptions{Action: self.c.Tr.Actions.Pull, BranchesBelow: branchesBelow} // if we have no upstream branch we need to set that first if !currentBranch.IsTrackingRemote() { @@ -250,11 +278,46 @@ func (self *SyncController) pull(currentBranch *models.Branch) error { return err } - return self.PullAux(currentBranch, PullFilesOptions{Action: action}) + return self.PullAux(currentBranch, opts) }) } - return self.PullAux(currentBranch, PullFilesOptions{Action: action}) + return self.PullAux(currentBranch, opts) +} + +// The branches stacked below the current one that can be updated to their +// upstream branches without losing anything: those that are behind them, and +// those that diverged from them only because they were rewritten +func (self *SyncController) updatableBranchesBelow(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 { + // Updating a branch that is checked out in another worktree changes + // the files there, which is more than pulling the current branch + // should do + if git_commands.CheckedOutByOtherWorktree(branch, self.c.Model().Worktrees) { + return false + } + + return branch.IsBehindForPull() && (!branch.IsAheadForPull() || branch.UpstreamRewritten.Load()) + }) +} + +func (self *SyncController) branchesBelowToUpdatePrompt(currentBranch *models.Branch, branchesBelow []*models.Branch) string { + intro := utils.ResolvePlaceholderString( + self.c.Tr.BranchesBelowHaveChangedOnRemote, + map[string]string{"branchName": currentBranch.Name}, + ) + lines := lo.Map(branchesBelow, func(branch *models.Branch, _ int) string { + divergence := "↓" + branch.BehindForPull + if branch.IsAheadForPull() { + divergence += "↑" + branch.AheadForPull + } + return fmt.Sprintf("%s %s", branch.Name, style.FgYellow.Sprint(divergence)) + }) + + return intro + "\n\n " + strings.Join(lines, "\n ") } func (self *SyncController) setCurrentBranchUpstream(upstream string) error { @@ -280,10 +343,35 @@ type PullFilesOptions struct { UpstreamBranch string FastForwardOnly bool Action string + + // Branches stacked below the current one, updated to their upstream + // branches before the current one is pulled + BranchesBelow []*models.Branch } func (self *SyncController) PullAux(currentBranch *models.Branch, opts PullFilesOptions) error { - return self.c.WithInlineStatus(currentBranch, types.ItemOperationPulling, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { + var updateBranchesBelow func(gocui.Task) error + if len(opts.BranchesBelow) > 0 { + var err error + updateBranchesBelow, err = self.c.Helpers().BranchesHelper.PrepareFastForward(opts.BranchesBelow) + if err != nil { + return err + } + } + + branches := append([]*models.Branch{currentBranch}, opts.BranchesBelow...) + return self.c.Helpers().BranchesHelper.WithInlineStatusOnBranches(branches, types.ItemOperationPulling, func(task gocui.Task) error { + // Update the branches below first. If one of them pointed into the + // commits that a rebasing pull rebases, the pull would move it when + // rebase.updateRefs is set, and updating it afterwards would fail. If + // updating them fails, the current branch isn't pulled either; the + // user can still choose to pull only the current branch. + if updateBranchesBelow != nil { + if err := updateBranchesBelow(task); err != nil { + return err + } + } + return self.pullWithLock(task, opts) }) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 3cfe7bd78..8e0bcb017 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -244,6 +244,9 @@ type TranslationSet struct { BranchesBelowHaveCommitsToPush string PushBranchAndBranchesBelow string PushOnlyCurrentBranch string + BranchesBelowHaveChangedOnRemote string + PullBranchAndBranchesBelow string + PullOnlyCurrentBranch string UpdatesRejected string UpdatesRejectedAndForcePushDisabled string CheckForUpdate string @@ -1232,7 +1235,7 @@ func EnglishTranslationSet() *TranslationSet { 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. 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.", + PullTooltip: "Pull changes from the remote for the current 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 changed on the remote, you are offered to update those too.", MergeConflictsTitle: "Merge conflicts", MergeConflictDescription_DD: "Conflict: this file was moved or renamed both in the current and the incoming changes, but to different destinations. I don't know which ones, but they should both show up as conflicts too (marked 'AU' and 'UA', respectively). The most likely resolution is to delete this file, and pick one of the destinations and delete the other.", MergeConflictDescription_AU: "Conflict: this file is the destination of a move or rename in the current changes, but was moved or renamed to a different destination in the incoming changes. That other destination should also show up as a conflict (marked 'UA'), as well as the file that both were renamed from (marked 'DD').", @@ -1413,6 +1416,9 @@ func EnglishTranslationSet() *TranslationSet { 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}}'", + BranchesBelowHaveChangedOnRemote: "The following branches stacked below '{{.branchName}}' have also changed on the remote:", + PullBranchAndBranchesBelow: "Pull all these branches in addition to the current one", + PullOnlyCurrentBranch: "Pull 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/pull_stacked_branches.go b/pkg/integration/tests/sync/pull_stacked_branches.go new file mode 100644 index 000000000..2b45b63cd --- /dev/null +++ b/pkg/integration/tests/sync/pull_stacked_branches.go @@ -0,0 +1,56 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PullStackedBranches = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Pull a stack of branches that was rebased on the remote, updating the branches below the current one too", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical" + }, + SetupRepo: func(shell *Shell) { + createStackRewrittenOnTheRemote(shell) + shell.Checkout("branch3") + shell.SetConfig("pull.rebase", "true") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Lines( + Contains("branch3 ↓3↑3"), + Contains("branch1 ↓1↑1"), + Contains("branch2 ↓2↑2"), + Contains("master"), + ) + + t.Views().Files().IsFocused().Press(keys.Universal.Pull) + + t.ExpectPopup().Menu(). + Title(Equals("Pull")). + ContainsLines( + Contains(" branch2 ↓2↑2"), + Contains(" branch1 ↓1↑1"), + ). + Select(Contains("Pull all these branches in addition to the current one")). + Confirm() + + t.Views().Branches(). + Lines( + Contains("branch3 ✓"), + Contains("branch1 ✓"), + Contains("branch2 ✓"), + Contains("master"), + ) + + t.Views().Commits(). + Lines( + Contains("three-rewritten"), + Contains("two-rewritten"), + Contains("one-rewritten"), + Contains("base"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/pull_stacked_branches_checked_out_in_other_worktree.go b/pkg/integration/tests/sync/pull_stacked_branches_checked_out_in_other_worktree.go new file mode 100644 index 000000000..592dc438c --- /dev/null +++ b/pkg/integration/tests/sync/pull_stacked_branches_checked_out_in_other_worktree.go @@ -0,0 +1,47 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PullStackedBranchesCheckedOutInOtherWorktree = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Pull a stack that was rebased on the remote; a branch below the current one that is checked out in another worktree is not offered for updating", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical" + }, + SetupRepo: func(shell *Shell) { + createStackRewrittenOnTheRemote(shell) + shell.Checkout("branch3") + shell.SetConfig("pull.rebase", "true") + + shell.AddWorktreeCheckout("branch1", "../linked-worktree") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files().IsFocused().Press(keys.Universal.Pull) + + t.ExpectPopup().Menu(). + Title(Equals("Pull")). + Lines( + Contains("The following branches stacked below 'branch3' have also changed on the remote:"), + Equals(""), + Equals(" branch2 ↓2↑2"), + Equals(""), + Contains("Pull all these branches in addition to the current one"), + Contains("Pull only 'branch3'"), + Contains("Cancel"), + ). + Select(Contains("Pull all these branches in addition to the current one")). + Confirm() + + t.Views().Branches(). + Lines( + Contains("branch3 ✓"), + Contains("branch1 (worktree linked-worktree) ↓1↑1"), + Contains("branch2 ✓"), + Contains("master"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/pull_stacked_branches_only_current.go b/pkg/integration/tests/sync/pull_stacked_branches_only_current.go new file mode 100644 index 000000000..f1cbfc6a2 --- /dev/null +++ b/pkg/integration/tests/sync/pull_stacked_branches_only_current.go @@ -0,0 +1,36 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PullStackedBranchesOnlyCurrent = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Decline updating the branches below the current one in a stack that was rebased on the remote, pulling only the current branch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical" + }, + SetupRepo: func(shell *Shell) { + createStackRewrittenOnTheRemote(shell) + shell.Checkout("branch3") + shell.SetConfig("pull.rebase", "true") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files().IsFocused().Press(keys.Universal.Pull) + + t.ExpectPopup().Menu(). + Title(Equals("Pull")). + Select(Contains("Pull only 'branch3'")). + Confirm() + + t.Views().Branches(). + Lines( + Contains("branch3 ✓"), + Contains("branch1 ↓1↑1"), + Contains("branch2 ↓2↑2"), + Contains("master"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/pull_stacked_branches_update_fails.go b/pkg/integration/tests/sync/pull_stacked_branches_update_fails.go new file mode 100644 index 000000000..cfadb051b --- /dev/null +++ b/pkg/integration/tests/sync/pull_stacked_branches_update_fails.go @@ -0,0 +1,44 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PullStackedBranchesUpdateFails = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Pull a stack that was rebased on the remote, when updating the branches below the current one fails; the current branch is not pulled then", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical" + }, + SetupRepo: func(shell *Shell) { + createStackRewrittenOnTheRemote(shell) + shell.Checkout("branch3") + shell.SetConfig("pull.rebase", "true") + + // Somebody deleted branch1 on the remote, which we haven't fetched yet + shell.RunCommand([]string{"git", "-C", "../origin", "branch", "-D", "branch1"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files().IsFocused().Press(keys.Universal.Pull) + + t.ExpectPopup().Menu(). + Title(Equals("Pull")). + Select(Contains("Pull all these branches in addition to the current one")). + Confirm() + + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("couldn't find remote ref")). + Confirm() + + t.Views().Branches(). + Lines( + Contains("branch3 ↓3↑3"), + Contains("branch1 ↓1↑1"), + Contains("branch2 ↓2↑2"), + Contains("master"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/pull_stacked_branches_with_local_commits.go b/pkg/integration/tests/sync/pull_stacked_branches_with_local_commits.go new file mode 100644 index 000000000..d9c426471 --- /dev/null +++ b/pkg/integration/tests/sync/pull_stacked_branches_with_local_commits.go @@ -0,0 +1,47 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PullStackedBranchesWithLocalCommits = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Pull a stack that was rebased on the remote; a branch below the current one that has a commit of its own is not offered for updating", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical" + }, + SetupRepo: func(shell *Shell) { + createStackRewrittenOnTheRemote(shell) + + shell.Checkout("branch2") + shell.EmptyCommit("mine") + shell.Checkout("branch3") + shell.RunCommand([]string{"git", "rebase", "--onto", "branch2", "branch2~1"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Lines( + Contains("branch3 ↓3↑4"), + Contains("branch1 ↓1↑1"), + Contains("branch2 ↓2↑3"), + Contains("master"), + ) + + t.Views().Files().IsFocused().Press(keys.Universal.Pull) + + t.ExpectPopup().Menu(). + Title(Equals("Pull")). + Lines( + Contains("The following branches stacked below 'branch3' have also changed on the remote:"), + Equals(""), + Equals(" branch1 ↓1↑1"), + Equals(""), + Contains("Pull all these branches in addition to the current one"), + Contains("Pull only 'branch3'"), + Contains("Cancel"), + ). + Cancel() + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index d859b3bc7..7ac9055ab 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -509,6 +509,11 @@ var tests = []*components.IntegrationTest{ sync.PullRebaseConflict, sync.PullRebaseInteractiveConflict, sync.PullRebaseInteractiveConflictDrop, + sync.PullStackedBranches, + sync.PullStackedBranchesCheckedOutInOtherWorktree, + sync.PullStackedBranchesOnlyCurrent, + sync.PullStackedBranchesUpdateFails, + sync.PullStackedBranchesWithLocalCommits, sync.Push, sync.PushAndAutoSetUpstream, sync.PushAndSetUpstream,