Auto-forward branches that are checked out in other worktrees

If a main branch is checked out in another worktree, auto-forwarding
after a fetch skips it. People who keep the main branch checked out in
their main worktree, and work on feature branches in linked worktrees,
therefore never get it forwarded.

Fast-forward such a branch in the worktree it is checked out in, as `f`
does. Skip the worktree if it has changes to tracked files, since
nobody asked for its files to change. Also skip it if it is mid-rebase
or mid-bisect, because its HEAD is then detached from the branch. A
branch in the current worktree is never forwarded. This covers the case
of the current worktree being mid-rebase on a main branch, whose ref
would otherwise be forwarded under the rebase.

Auto-forwarding now writes the same reflog message as `f`
("lazygit: update to upstream branch").

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-09-27 11:05:47 +02:00
co-authored by Claude Opus 5.5
parent 1b4fc02040
commit 0a99f65be3
9 changed files with 187 additions and 19 deletions
+3 -1
View File
@@ -469,7 +469,9 @@ git:
# If not "none", lazygit will automatically fast-forward local branches to match
# their upstream after fetching. Applies to branches that are not the currently
# checked out branch, and only to those that are strictly behind their upstream
# (as opposed to diverged).
# (as opposed to diverged). A branch that is checked out in another worktree is
# fast-forwarded there, unless that worktree has changes to tracked files or is
# in the middle of a rebase or bisect.
# Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
autoForwardBranches: onlyMainBranches
+1 -1
View File
@@ -328,7 +328,7 @@ type GitConfig struct {
AutoRefresh bool `yaml:"autoRefresh"`
// If true, poll the repo periodically for external ref changes (commits, branch updates, checkouts made outside lazygit) and refresh when one is detected. Independent of autoRefresh, which only governs the files panel.
AutoDetectExternalChanges bool `yaml:"autoDetectExternalChanges"`
// If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged).
// If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged). A branch that is checked out in another worktree is fast-forwarded there, unless that worktree has changes to tracked files or is in the middle of a rebase or bisect.
// Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
AutoForwardBranches string `yaml:"autoForwardBranches" jsonschema:"enum=none,enum=onlyMainBranches,enum=allBranches"`
// If true, pass the --all arg to git fetch
+40 -11
View File
@@ -640,20 +640,32 @@ func (self *BranchesHelper) AutoForwardBranches(background bool) {
}
allBranches := self.c.UserConfig().Git.AutoForwardBranches == "allBranches"
updateCommands := ""
toForward := []*branchToForward{}
// The first branch is the currently checked out branch; skip it
for _, branch := range branches[1:] {
if branch.RemoteBranchStoredLocally() &&
!self.checkedOutByOtherWorktree(branch) &&
(allBranches || lo.Contains(self.c.UserConfig().Git.MainBranches, branch.Name)) {
isStrictlyBehind := branch.IsBehindForPull() && !branch.IsAheadForPull()
if isStrictlyBehind {
updateCommands += fmt.Sprintf("update %s %s %s\n", branch.FullRefName(), branch.FullUpstreamRefName(), branch.CommitHash)
}
if !branch.RemoteBranchStoredLocally() ||
!(allBranches || lo.Contains(self.c.UserConfig().Git.MainBranches, branch.Name)) {
continue
}
isStrictlyBehind := branch.IsBehindForPull() && !branch.IsAheadForPull()
if !isStrictlyBehind {
continue
}
// Changing the files of the current worktree without being asked to
// would be surprising. A worktree that is mid-rebase or mid-bisect has
// its HEAD detached from the branch, so the branch can't be moved
// there, and neither can it in a worktree whose directory is missing.
worktree, _ := self.worktreeForBranch(branch)
if worktree != nil && (worktree.IsCurrent || worktree.IsRebasingOrBisecting || worktree.IsPathMissing) {
continue
}
toForward = append(toForward, &branchToForward{branch: branch, worktree: worktree})
}
if updateCommands == "" {
if len(toForward) == 0 {
return
}
@@ -662,9 +674,26 @@ func (self *BranchesHelper) AutoForwardBranches(background bool) {
git := self.c.Git()
onWorker := lo.Ternary(background, self.c.OnWorkerBackground, self.c.OnWorker)
onWorker(func(gocui.Task) error {
// Only change the files of another worktree while the user has no
// changes of their own there
toForward := lo.Filter(toForward, func(f *branchToForward, _ int) bool {
if f.worktree == nil {
return true
}
worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree)
hasChanges, err := git.WorkingTree.HasChangesToTrackedFiles(worktreeGitDir, worktreePath)
if err != nil {
self.c.Log.Errorf("Failed to check worktree %s for changes: %v", f.worktree.Name, err)
}
return err == nil && !hasChanges
})
if len(toForward) == 0 {
return nil
}
self.c.LogAction(self.c.Tr.Actions.AutoForwardBranches)
self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false)
err := git.Branch.UpdateBranchRefs(updateCommands, "lazygit: fast-forward to upstream branch")
err := self.forwardBranches(git, toForward)
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Background: background})
@@ -6,7 +6,7 @@ import (
)
var FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Fetch from remote and auto-forward branches with config set to 'allBranches'; check that this skips branches checked out by another worktree",
Description: "Fetch from remote and auto-forward branches with config set to 'allBranches'; check that a branch checked out by another worktree is forwarded there, unless that worktree has changes or is mid-rebase",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
@@ -17,10 +17,14 @@ var FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree = NewIntegra
shell.CreateNCommits(3)
shell.NewBranch("feature")
shell.NewBranch("diverged")
shell.NewBranch("dirty")
shell.NewBranch("rebasing")
shell.CloneIntoRemote("origin")
shell.SetBranchUpstream("master", "origin/master")
shell.SetBranchUpstream("feature", "origin/feature")
shell.SetBranchUpstream("diverged", "origin/diverged")
shell.SetBranchUpstream("dirty", "origin/dirty")
shell.SetBranchUpstream("rebasing", "origin/rebasing")
shell.Checkout("master")
shell.HardReset("HEAD^")
shell.Checkout("feature")
@@ -28,30 +32,71 @@ var FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree = NewIntegra
shell.Checkout("diverged")
shell.HardReset("HEAD~2")
shell.EmptyCommit("local")
shell.Checkout("dirty")
shell.HardReset("HEAD~2")
shell.Checkout("rebasing")
shell.HardReset("HEAD^")
shell.NewBranch("checked-out")
shell.AddWorktreeCheckout("master", "../linked-worktree")
shell.AddWorktreeCheckout("dirty", "../dirty-worktree")
shell.UpdateFile("../dirty-worktree/file01.txt", "changed")
shell.AddWorktreeCheckout("rebasing", "../rebasing-worktree")
// the failing exec stops the rebase after picking commit-02, with HEAD
// detached from the branch
shell.RunCommandExpectError([]string{"git", "-C", "../rebasing-worktree", "rebase", "--exec", "false", "HEAD^"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Lines(
Contains("checked-out").IsSelected(),
Contains("dirty (worktree dirty-worktree) ↓2").DoesNotContain("↑"),
Contains("diverged ↓2↑1"),
Contains("feature ↓2").DoesNotContain("↑"),
Contains("master (worktree linked-worktree) ↓1").DoesNotContain("↑"),
Contains("rebasing (worktree rebasing-worktree) ↓1").DoesNotContain("↑"),
)
t.Views().Files().
IsFocused().
Press(keys.Files.Fetch)
// AutoForwardBranches is "allBranches": both master and feature get forwarded
// AutoForwardBranches is "allBranches": feature gets forwarded, and so
// does master in the worktree it is checked out in
t.Views().Branches().
Lines(
Contains("checked-out").IsSelected(),
Contains("dirty (worktree dirty-worktree) ↓2"),
Contains("diverged ↓2↑1"),
Contains("feature ✓"),
Contains("master (worktree linked-worktree) ↓1"),
Contains("master (worktree linked-worktree) ✓"),
Contains("rebasing (worktree rebasing-worktree) ↓1"),
)
// The files of the linked worktree have moved along with master
t.Views().Worktrees().
Focus().
NavigateToLine(Contains("linked-worktree")).
PressPrimaryAction()
t.Views().Files().
Focus().
IsEmpty()
// The rebase in progress was left alone
t.Views().Worktrees().
Focus().
NavigateToLine(Contains("rebasing-worktree")).
PressPrimaryAction()
t.Views().Commits().
Lines(
Contains("─── Pending rebase todos"),
Contains("─── Commits"),
Contains("commit-02"),
Contains("commit-01"),
)
},
})
@@ -0,0 +1,42 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FetchAndAutoForwardBranchesBeingRebasedInCurrentWorktree = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Auto-forward skips a main branch that is being rebased in the current worktree",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Git.AutoForwardBranches = "onlyMainBranches"
},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(3)
shell.CloneIntoRemote("origin")
shell.SetBranchUpstream("master", "origin/master")
shell.HardReset("HEAD^")
// the failing exec stops the rebase after picking commit-02, with HEAD
// detached from master
shell.RunCommandExpectError([]string{"git", "rebase", "--exec", "false", "HEAD^"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Lines(
Contains("(no branch, rebasing master)"),
Contains("master ↓1"),
)
t.Views().Files().
IsFocused().
Press(keys.Files.Fetch)
t.Views().Branches().
Lines(
Contains("(no branch, rebasing master)"),
Contains("master ↓1"),
)
},
})
@@ -0,0 +1,48 @@
package sync
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FetchAndAutoForwardBranchesCheckedOutInOtherWorktreeWithStaleSubmodule = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Auto-forward a main branch that is checked out in another worktree whose submodule is checked out at a different commit than the one recorded",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Git.AutoForwardBranches = "onlyMainBranches"
config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical"
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
shell.CloneIntoSubmodule("sub", "sub")
shell.Commit("add submodule")
shell.EmptyCommit("two")
shell.CloneIntoRemote("origin")
shell.SetBranchUpstream("master", "origin/master")
shell.HardReset("HEAD^")
shell.NewBranch("other")
shell.AddWorktreeCheckout("master", "../linked-worktree")
shell.RunCommand([]string{"git", "-C", "../linked-worktree", "submodule", "update", "--init"})
shell.RunCommand([]string{"git", "-C", "../linked-worktree/sub", "commit", "--allow-empty", "-m", "newer"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Lines(
Contains("other").IsSelected(),
Contains("master (worktree linked-worktree) ↓1"),
)
t.Views().Files().
IsFocused().
Press(keys.Files.Fetch)
t.Views().Branches().
Lines(
Contains("other").IsSelected(),
Contains("master (worktree linked-worktree) ✓"),
)
},
})
@@ -6,7 +6,7 @@ import (
)
var FetchAndAutoForwardBranchesWorktreeAddedAfterStartup = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Auto-forward skips a main branch that was externally checked out in a linked worktree after lazygit started",
Description: "Auto-forward a main branch in the linked worktree that it was externally checked out in after lazygit started",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
@@ -43,7 +43,7 @@ var FetchAndAutoForwardBranchesWorktreeAddedAfterStartup = NewIntegrationTest(Ne
t.Views().Branches().
Lines(
Contains("feature").IsSelected(),
Contains("master (worktree linked-worktree) ↓1"),
Contains("master (worktree linked-worktree) ✓"),
Contains("wt-branch").DoesNotContain("worktree"),
)
+2
View File
@@ -489,6 +489,8 @@ var tests = []*components.IntegrationTest{
sync.FastForwardRewrittenStackOfBranchesWithLocalCommits,
sync.FetchAndAutoForwardBranchesAllBranches,
sync.FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree,
sync.FetchAndAutoForwardBranchesBeingRebasedInCurrentWorktree,
sync.FetchAndAutoForwardBranchesCheckedOutInOtherWorktreeWithStaleSubmodule,
sync.FetchAndAutoForwardBranchesNone,
sync.FetchAndAutoForwardBranchesOnlyMainBranches,
sync.FetchAndAutoForwardBranchesWorktreeAddedAfterStartup,
+1 -1
View File
@@ -421,7 +421,7 @@
"onlyMainBranches",
"allBranches"
],
"description": "If not \"none\", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged).\nPossible values: 'none' | 'onlyMainBranches' | 'allBranches'",
"description": "If not \"none\", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged). A branch that is checked out in another worktree is fast-forwarded there, unless that worktree has changes to tracked files or is in the middle of a rebase or bisect.\nPossible values: 'none' | 'onlyMainBranches' | 'allBranches'",
"default": "onlyMainBranches"
},
"fetchAll": {