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,