diff --git a/docs-master/Config.md b/docs-master/Config.md index 09781c9de..c32a813dd 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -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 diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index ce7ebd914..f3e4597d6 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -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 diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index c6a9dbc6b..c5111f80a 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -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}) diff --git a/pkg/integration/tests/sync/fetch_and_auto_forward_branches_all_branches_checked_out_in_other_worktree.go b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_all_branches_checked_out_in_other_worktree.go index 428c85e07..bf7b2a5aa 100644 --- a/pkg/integration/tests/sync/fetch_and_auto_forward_branches_all_branches_checked_out_in_other_worktree.go +++ b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_all_branches_checked_out_in_other_worktree.go @@ -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"), ) }, }) diff --git a/pkg/integration/tests/sync/fetch_and_auto_forward_branches_being_rebased_in_current_worktree.go b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_being_rebased_in_current_worktree.go new file mode 100644 index 000000000..b2bc3b611 --- /dev/null +++ b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_being_rebased_in_current_worktree.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fetch_and_auto_forward_branches_checked_out_in_other_worktree_with_stale_submodule.go b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_checked_out_in_other_worktree_with_stale_submodule.go new file mode 100644 index 000000000..bbaadd044 --- /dev/null +++ b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_checked_out_in_other_worktree_with_stale_submodule.go @@ -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) ✓"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fetch_and_auto_forward_branches_worktree_added_after_startup.go b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_worktree_added_after_startup.go index bee14276a..c4107a8ad 100644 --- a/pkg/integration/tests/sync/fetch_and_auto_forward_branches_worktree_added_after_startup.go +++ b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_worktree_added_after_startup.go @@ -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"), ) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 76fae7e28..d859b3bc7 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -489,6 +489,8 @@ var tests = []*components.IntegrationTest{ sync.FastForwardRewrittenStackOfBranchesWithLocalCommits, sync.FetchAndAutoForwardBranchesAllBranches, sync.FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree, + sync.FetchAndAutoForwardBranchesBeingRebasedInCurrentWorktree, + sync.FetchAndAutoForwardBranchesCheckedOutInOtherWorktreeWithStaleSubmodule, sync.FetchAndAutoForwardBranchesNone, sync.FetchAndAutoForwardBranchesOnlyMainBranches, sync.FetchAndAutoForwardBranchesWorktreeAddedAfterStartup, diff --git a/schema-master/config.json b/schema-master/config.json index 526b2fbf0..f2bef0066 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -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": {