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) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-09-27 11:13:15 +02:00
co-authored by Claude Opus 5.5
parent 6919a2aa47
commit 26ad23a8d2
12 changed files with 346 additions and 9 deletions
+8
View File
@@ -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.
+1 -1
View File
@@ -11,7 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Scroll down main window | |
| `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. |
| `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. 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.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
+1 -1
View File
@@ -11,7 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | 메인 패널을 아래로로 스크롤 | |
| `` @ `` | 명령어 로그 메뉴 열기 | View options for the command log e.g. show/hide the command log and focus the command log. |
| `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. 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.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` } `` | Diff 보기의 변경 사항 주위에 표시되는 컨텍스트의 크기를 늘리기 | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
+1 -1
View File
@@ -11,7 +11,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Прокрутить вниз главную панель | |
| `` @ `` | Открыть меню журнала команд | View options for the command log e.g. show/hide the command log and focus the command log. |
| `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. 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.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` } `` | Увеличить размер контекста, отображаемого вокруг изменений в просмотрщике сравнении | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
+93 -5
View File
@@ -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)
})
}
+7 -1
View File
@@ -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",
@@ -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"),
)
},
})
@@ -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"),
)
},
})
@@ -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"),
)
},
})
@@ -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"),
)
},
})
@@ -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()
},
})
+5
View File
@@ -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,