diff --git a/docs-master/Stacked_Branches.md b/docs-master/Stacked_Branches.md index 84a11507e..c9b080e11 100644 --- a/docs-master/Stacked_Branches.md +++ b/docs-master/Stacked_Branches.md @@ -24,3 +24,20 @@ force-pushing every branch one by one; you are asked to confirm the force push once for all of them. Only branches that already have an upstream are included. Each of them is pushed to where `git push` would push it if it were checked out, so your push configuration applies to them as usual. + +When somebody else rebases the stack and force-pushes it, all your branches +show up as diverged, for example `↓5↑3`, even though the commits they are ahead +by are only the old versions of the ones that are now on the remote. Lazygit +tells this apart from a branch that carries work of your own, and shows the +divergence dimmed for such a branch. Pressing `f` on it resets it to its +upstream instead of refusing, so you don't have to check the branch out and pull +it. Lazygit only does this when every commit of the branch was on its remote +branch at some point. It finds that out from the reflog of the remote-tracking +branch. Reflogs are enabled by default, except in a bare repository; if you work +in one with linked worktrees, set `core.logAllRefUpdates` to true there to make +this work. + +`f` works on a [range selection](Range_Select.md) too, so you can select the +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. diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 835a353c5..5010a4f75 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -189,7 +189,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` d `` | Delete | View delete options for local/remote branch. | | `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. | | `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) | -| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. | +| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). | | `` T `` | New tag | | | `` s `` | Sort order | | | `` g `` | Reset | | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 653024df5..918f15d55 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -223,7 +223,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` d `` | 삭제 | View delete options for local/remote branch. | | `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. | | `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) | -| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. | +| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). | | `` T `` | 태그를 생성 | | | `` s `` | Sort order | | | `` g `` | View reset options | | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 0f01dea7c..524eddcab 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -113,7 +113,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` d `` | Verwijderen | View delete options for local/remote branch. | | `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. | | `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) | -| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. | +| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). | | `` T `` | Creëer tag | | | `` s `` | Sort order | | | `` g `` | Bekijk reset opties | | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 379429163..716f11f0e 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -223,7 +223,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` d `` | Delete | View delete options for local/remote branch. | | `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. | | `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) | -| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. | +| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). | | `` T `` | Создать тег | | | `` s `` | Порядок сортировки | | | `` g `` | Просмотреть параметры сброса | | diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 53701a04e..5904dfe4d 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -145,7 +145,7 @@ func NewGitCommandAux( gitHubCommands := git_commands.NewGitHubCommands(gitCommon) hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon) - branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands) + branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, branchCommands.HasLocalOnlyCommits, configCommands) commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd) commitLoader := git_commands.NewCommitLoader(cmn, cmd, statusCommands.WorkingTreeState, gitCommon) reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd) diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index a55278b5b..19dba12fe 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -285,11 +285,26 @@ func (self *BranchCommands) Merge(branchName string, variant MergeVariant) error return self.cmd.New(cmdArgs).Run() } -// Returns whether refName can be fast-forward merged into the current branch -func (self *BranchCommands) CanDoFastForwardMerge(refName string) bool { +// Fast-forwards the branch that is checked out in the given worktree to the +// given ref. Fails if that can't be done without a merge commit. Pass empty +// strings for the worktree to use the current one. +func (self *BranchCommands) FastForwardMerge(refName string, worktreeGitDir string, worktreePath string) error { + cmdArgs := NewGitCmd("merge"). + Arg("--ff-only"). + Arg(refName). + GitDirIf(worktreeGitDir != "", worktreeGitDir). + WorktreePathIf(worktreePath != "", worktreePath). + ToArgv() + + return self.cmd.New(cmdArgs).Run() +} + +// Returns whether the first ref is an ancestor of the second one, which also +// means that the second one can be fast-forward merged into the first one +func (self *BranchCommands) IsAncestor(ancestorRefName string, refName string) bool { cmdArgs := NewGitCmd("merge-base"). Arg("--is-ancestor"). - Arg("HEAD", refName). + Arg(ancestorRefName, refName). ToArgv() err := self.cmd.New(cmdArgs).DontLog().Run() return err == nil @@ -353,9 +368,85 @@ func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches * return stdout == "", nil } -func (self *BranchCommands) UpdateBranchRefs(updateCommands string) error { +// Returns whether the given branch has commits of its own, meaning commits +// that its remote branch never contained. Those are the commits that would be +// lost if we reset the branch to its upstream. +// +// A branch that has diverged from its upstream doesn't necessarily have any +// commits of its own. If somebody else rewrote the remote branch and +// force-pushed it, our branch is still at the commits it had before, and all +// of those were on the remote branch at some point. The reflog of the +// remote-tracking branch records the values it had before it was rewritten, so +// a commit that was ever on the remote branch is contained in one of them. +func (self *BranchCommands) HasLocalOnlyCommits(branch *models.Branch) (bool, error) { + upstreamValues := append( + []string{branch.FullUpstreamRefName()}, + self.previousUpstreamValues(branch.FullUpstreamRefName())..., + ) + + cmdArgs := NewGitCmd("rev-list"). + Arg("--max-count=1"). + // A value that the remote-tracking branch had long ago might not be + // available any more, e.g. in a partial clone. Skip it rather than + // failing; it only means we exclude fewer commits. + Arg("--ignore-missing"). + Arg(branch.FullRefName()). + Arg(lo.Map(upstreamValues, func(value string, _ int) string { + return "^" + value + })...). + Arg("--"). + ToArgv() + + stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs() + if err != nil { + return false, err + } + + return stdout != "", nil +} + +// Returns the values that the given remote-tracking branch had before its +// current one, as far back as its reflog goes. Returns nothing if the reflog +// is unavailable, for example because core.logAllRefUpdates is false; a branch +// that is strictly behind its upstream is recognized without it. +func (self *BranchCommands) previousUpstreamValues(upstreamRef string) []string { + cmdArgs := NewGitCmd("reflog"). + Arg("show"). + Arg("--format=%H"). + Arg(upstreamRef). + ToArgv() + + stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs() + if err != nil { + return nil + } + + // Each entry holds the value that the ref was updated to. + values := utils.SplitLines(stdout) + + // The value it had before the oldest entry is that entry's old value, and + // the only way to name it is @{}. It doesn't exist + // if the oldest entry is the one that created the ref, and asking for it + // then is an error rather than an empty result. + cmdArgs = NewGitCmd("rev-parse"). + Arg("-q", "--verify"). + Arg(fmt.Sprintf("%s@{%d}", upstreamRef, len(values))). + ToArgv() + + if stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs(); err == nil { + values = append(values, strings.TrimSpace(stdout)) + } + + return values +} + +// Moves branches by writing refs directly. The reflog message is what +// `git reflog ` shows for the update; it is the only hint about who +// moved the branch, as no git command shows up in the reflog for this. +func (self *BranchCommands) UpdateBranchRefs(updateCommands string, reflogMessage string) error { cmdArgs := NewGitCmd("update-ref"). Arg("--stdin"). + Arg("-m", reflogMessage). ToArgv() return self.cmd.New(cmdArgs).SetStdin(updateCommands).Run() diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index e4ccc6ab7..6091a4679 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -44,6 +44,7 @@ type BranchLoader struct { *GitCommon cmd oscommands.ICmdObjBuilder getCurrentBranchInfo func() (BranchInfo, error) + hasLocalOnlyCommits func(*models.Branch) (bool, error) config BranchLoaderConfigCommands } @@ -52,6 +53,7 @@ func NewBranchLoader( gitCommon *GitCommon, cmd oscommands.ICmdObjBuilder, getCurrentBranchInfo func() (BranchInfo, error), + hasLocalOnlyCommits func(*models.Branch) (bool, error), config BranchLoaderConfigCommands, ) *BranchLoader { return &BranchLoader{ @@ -59,6 +61,7 @@ func NewBranchLoader( GitCommon: gitCommon, cmd: cmd, getCurrentBranchInfo: getCurrentBranchInfo, + hasLocalOnlyCommits: hasLocalOnlyCommits, config: config, } } @@ -67,7 +70,7 @@ func NewBranchLoader( func (self *BranchLoader) Load(reflogCommits []*models.Commit, mainBranches *MainBranches, oldBranches []*models.Branch, - loadBehindCounts bool, + loadExtraInfo bool, onWorker func(func() error), renderFunc func(), ) ([]*models.Branch, error) { @@ -135,24 +138,63 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit, branch.UpstreamBranch = match.Merge } - // If the branch already existed, take over its BehindBaseBranch value - // to reduce flicker + // If the branch already existed, take over the values that are + // determined in the background, to reduce flicker if oldBranch, found := lo.Find(oldBranches, func(b *models.Branch) bool { return b.Name == branch.Name }); found { branch.BehindBaseBranch.Store(oldBranch.BehindBaseBranch.Load()) + branch.UpstreamRewritten.Store(oldBranch.UpstreamRewritten.Load()) } } - if loadBehindCounts && self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" { + if loadExtraInfo { + if self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" { + onWorker(func() error { + return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc) + }) + } + onWorker(func() error { - return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc) + return self.checkForRewrittenUpstreams(branches, renderFunc) }) } return branches, nil } +// For each branch that has diverged from its upstream, determines whether the +// divergence comes from the upstream branch having been rewritten, and stores +// the answer in the branch. A branch that we can't determine it for keeps the +// answer "no", so that we don't offer anything we aren't sure about. +func (self *BranchLoader) checkForRewrittenUpstreams(branches []*models.Branch, renderFunc func()) error { + t := time.Now() + errg := errgroup.Group{} + + for _, branch := range branches { + if !branch.IsAheadForPull() || !branch.IsBehindForPull() { + branch.UpstreamRewritten.Store(false) + continue + } + + errg.Go(func() error { + hasLocalOnlyCommits, err := self.hasLocalOnlyCommits(branch) + if err != nil { + // Not worth bothering the user about; it only means that we + // don't show this branch differently. + self.Log.Errorf("Failed to check whether branch %s has commits of its own: %v", branch.Name, err) + } + branch.UpstreamRewritten.Store(err == nil && !hasLocalOnlyCommits) + return nil + }) + } + + err := errg.Wait() + self.Log.Debugf("time to check for rewritten upstreams for all branches: %s", time.Since(t)) + renderFunc() + return err +} + func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches( branches []*models.Branch, mainBranches *MainBranches, diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go index 080c5e2f7..d549f4c39 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -6,8 +6,10 @@ import ( "testing" "time" + "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/sasha-s/go-deadlock" "github.com/stretchr/testify/assert" ) @@ -291,3 +293,62 @@ func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) { runner.CheckForMissingCalls() } + +func TestCheckForRewrittenUpstreams(t *testing.T) { + branch := func(name string, ahead string, behind string) *models.Branch { + return &models.Branch{ + Name: name, + UpstreamRemote: "origin", + UpstreamBranch: name, + AheadForPull: ahead, + BehindForPull: behind, + } + } + + notDiverged := branch("not-diverged", "0", "2") + rewritten := branch("rewritten", "3", "5") + ownCommits := branch("own-commits", "3", "5") + failing := branch("failing", "1", "1") + + // A branch that is no longer diverged must lose the value it had before + notDiverged.UpstreamRewritten.Store(true) + + branches := []*models.Branch{notDiverged, rewritten, ownCommits, failing} + + var mutex deadlock.Mutex + queried := []string{} + hasLocalOnlyCommits := func(branch *models.Branch) (bool, error) { + mutex.Lock() + queried = append(queried, branch.Name) + mutex.Unlock() + + switch branch.Name { + case "own-commits": + return true, nil + case "failing": + return false, errors.New("error") + default: + return false, nil + } + } + + gitCommon := buildGitCommon(commonDeps{}) + loader := &BranchLoader{ + Common: gitCommon.Common, + GitCommon: gitCommon, + cmd: gitCommon.cmd, + hasLocalOnlyCommits: hasLocalOnlyCommits, + } + + rendered := false + err := loader.checkForRewrittenUpstreams(branches, func() { rendered = true }) + assert.NoError(t, err) + assert.True(t, rendered, "renderFunc should have been called") + + assert.ElementsMatch(t, []string{"rewritten", "own-commits", "failing"}, queried, + "only diverged branches should be looked at") + assert.False(t, notDiverged.UpstreamRewritten.Load()) + assert.True(t, rewritten.UpstreamRewritten.Load()) + assert.False(t, ownCommits.UpstreamRewritten.Load()) + assert.False(t, failing.UpstreamRewritten.Load(), "a failed check should not claim anything") +} diff --git a/pkg/commands/git_commands/branch_test.go b/pkg/commands/git_commands/branch_test.go index a0c0096b9..97e5ba544 100644 --- a/pkg/commands/git_commands/branch_test.go +++ b/pkg/commands/git_commands/branch_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/go-errors/errors" + "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/config" "github.com/stretchr/testify/assert" @@ -317,3 +318,111 @@ func TestBranchCurrentBranchInfo(t *testing.T) { }) } } + +func TestBranchHasLocalOnlyCommits(t *testing.T) { + type scenario struct { + testName string + runner *oscommands.FakeCmdObjRunner + test func(bool, error) + } + + branch := &models.Branch{ + Name: "branch", + UpstreamRemote: "origin", + UpstreamBranch: "branch", + } + + scenarios := []scenario{ + { + "branch is strictly behind its upstream, and there are no reflogs", + oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, "", nil). + ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{0}"}, "", errors.New("error")). + ExpectGitArgs([]string{ + "rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch", + "^refs/remotes/origin/branch", "--", + }, "", nil), + func(hasLocalOnlyCommits bool, err error) { + assert.NoError(t, err) + assert.False(t, hasLocalOnlyCommits) + }, + }, + { + "the upstream branch was rewritten, so all our commits were on it before", + oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, + "1111111111111111111111111111111111111111\n2222222222222222222222222222222222222222\n", nil). + ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{2}"}, + "3333333333333333333333333333333333333333\n", nil). + ExpectGitArgs([]string{ + "rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch", + "^refs/remotes/origin/branch", + "^1111111111111111111111111111111111111111", + "^2222222222222222222222222222222222222222", + "^3333333333333333333333333333333333333333", + "--", + }, "", nil), + func(hasLocalOnlyCommits bool, err error) { + assert.NoError(t, err) + assert.False(t, hasLocalOnlyCommits) + }, + }, + { + "the oldest reflog entry is the one that created the ref", + oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, + "1111111111111111111111111111111111111111\n", nil). + ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{1}"}, "", errors.New("error")). + ExpectGitArgs([]string{ + "rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch", + "^refs/remotes/origin/branch", + "^1111111111111111111111111111111111111111", + "--", + }, "", nil), + func(hasLocalOnlyCommits bool, err error) { + assert.NoError(t, err) + assert.False(t, hasLocalOnlyCommits) + }, + }, + { + "the branch has a commit that was never on the upstream branch", + oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, + "1111111111111111111111111111111111111111\n", nil). + ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{1}"}, + "2222222222222222222222222222222222222222\n", nil). + ExpectGitArgs([]string{ + "rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch", + "^refs/remotes/origin/branch", + "^1111111111111111111111111111111111111111", + "^2222222222222222222222222222222222222222", + "--", + }, "4444444444444444444444444444444444444444\n", nil), + func(hasLocalOnlyCommits bool, err error) { + assert.NoError(t, err) + assert.True(t, hasLocalOnlyCommits) + }, + }, + { + "bubbles up an error from rev-list", + oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, "", nil). + ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{0}"}, "", errors.New("error")). + ExpectGitArgs([]string{ + "rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch", + "^refs/remotes/origin/branch", "--", + }, "", errors.New("error")), + func(hasLocalOnlyCommits bool, err error) { + assert.Error(t, err) + }, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + instance := buildBranchCommands(commonDeps{runner: s.runner}) + s.test(instance.HasLocalOnlyCommits(branch)) + s.runner.CheckForMissingCalls() + }) + } +} diff --git a/pkg/commands/git_commands/sync.go b/pkg/commands/git_commands/sync.go index 1a20a3293..603e5b8eb 100644 --- a/pkg/commands/git_commands/sync.go +++ b/pkg/commands/git_commands/sync.go @@ -1,9 +1,12 @@ package git_commands import ( + "fmt" + "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/samber/lo" ) type SyncCommands struct { @@ -111,15 +114,25 @@ func (self *SyncCommands) Pull(task gocui.Task, opts PullOptions) error { return self.cmd.New(cmdArgs).AddEnvVars("GIT_SEQUENCE_EDITOR=:").PromptOnCredentialRequest(task).Run() } -func (self *SyncCommands) FastForward( +// Fetches the given branches of the given remote, updating their +// remote-tracking branches. Local branches are left alone, including the ones +// that track them. +func (self *SyncCommands) FetchRemoteBranches( task gocui.Task, - branchName string, remoteName string, - remoteBranchName string, + remoteBranchNames []string, ) error { + // The explicit destinations and the leading + make sure that the + // remote-tracking branches are updated even when the remote branches were + // rewritten, whatever the remote's fetch refspec says + refspecs := lo.Map(remoteBranchNames, func(remoteBranchName string, _ int) string { + return fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s", + remoteBranchName, remoteName, remoteBranchName) + }) + cmdArgs := self.fetchCommandBuilder(false). Arg(remoteName). - Arg("refs/heads/" + remoteBranchName + ":" + branchName). + Arg(refspecs...). ToArgv() return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 846b359b3..1ab37fa6a 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -524,6 +524,42 @@ func (self *WorkingTreeCommands) ResetSoft(ref string) error { return self.cmd.New(cmdArgs).Run() } +// ResetKeep runs `git reset --keep` in the given worktree, which moves the +// checked out branch to the given ref while keeping local modifications. It +// fails rather than overwriting a file that differs between the two commits. +// Pass empty strings for the worktree to use the current one. +func (self *WorkingTreeCommands) ResetKeep(ref string, worktreeGitDir string, worktreePath string) error { + cmdArgs := NewGitCmd("reset").Arg("--keep", ref). + GitDirIf(worktreeGitDir != "", worktreeGitDir). + WorktreePathIf(worktreePath != "", worktreePath). + ToArgv() + + return self.cmd.New(cmdArgs).Run() +} + +// Returns whether the given worktree has changes to tracked files, either in +// its working tree or in its index. Untracked files don't count, and neither do +// submodules. A submodule that is checked out at a different commit than the +// one recorded, or that has changes of its own, doesn't get in the way of +// moving the branch, because moving it leaves the submodules alone. Pass empty +// strings for the worktree to use the current one. +func (self *WorkingTreeCommands) HasChangesToTrackedFiles(worktreeGitDir string, worktreePath string) (bool, error) { + cmdArgs := NewGitCmd("status"). + Arg("--porcelain"). + Arg("--untracked-files=no"). + Arg("--ignore-submodules"). + GitDirIf(worktreeGitDir != "", worktreeGitDir). + WorktreePathIf(worktreePath != "", worktreePath). + ToArgv() + + stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs() + if err != nil { + return false, err + } + + return stdout != "", nil +} + func (self *WorkingTreeCommands) ResetMixed(ref string) error { cmdArgs := NewGitCmd("reset").Arg("--mixed", ref). ToArgv() diff --git a/pkg/commands/git_commands/worktree_loader.go b/pkg/commands/git_commands/worktree_loader.go index f7577c870..ed5dc2f46 100644 --- a/pkg/commands/git_commands/worktree_loader.go +++ b/pkg/commands/git_commands/worktree_loader.go @@ -140,12 +140,14 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { rebasedBranch, ok := self.rebasedBranch(worktree) if ok { worktree.Branch = rebasedBranch + worktree.IsRebasingOrBisecting = true continue } bisectedBranch, ok := self.bisectedBranch(worktree) if ok { worktree.Branch = bisectedBranch + worktree.IsRebasingOrBisecting = true continue } } diff --git a/pkg/commands/models/branch.go b/pkg/commands/models/branch.go index 29b8fccf0..b01e5a7ef 100644 --- a/pkg/commands/models/branch.go +++ b/pkg/commands/models/branch.go @@ -47,6 +47,13 @@ type Branch struct { // determined yet, or up to date with base branch. (We don't need to // distinguish the two, as we don't draw anything in both cases.) BehindBaseBranch atomic.Int32 + + // Whether the branch has diverged from its upstream because the upstream + // branch was rewritten, and not because the branch has commits of its own. + // Such a branch can be reset to its upstream without losing anything. + // False for branches that haven't diverged, and for those we haven't + // determined it for yet. + UpstreamRewritten atomic.Bool } func (b *Branch) FullRefName() string { diff --git a/pkg/commands/models/worktree.go b/pkg/commands/models/worktree.go index 053a3f101..a1f00f95d 100644 --- a/pkg/commands/models/worktree.go +++ b/pkg/commands/models/worktree.go @@ -19,6 +19,9 @@ type Worktree struct { // * the worktree is mid-rebase on the branch // * the worktree is mid-bisect on the branch Branch string + // If true, the worktree is mid-rebase or mid-bisect on Branch, so its HEAD + // is detached rather than pointing at the branch + IsRebasingOrBisecting bool // The HEAD sha of the worktree. Always populated (even when Branch is set). // Used for display when Branch is empty (detached HEAD state). Head string diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index f01cf2c0b..d4060e34e 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -7,7 +7,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/presentation" @@ -144,8 +143,8 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty }, { Keys: opts.GetKeys(opts.Config.Branches.FastForward), - Handler: self.withItem(self.fastForward), - GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)), + Handler: self.withItems(self.fastForward), + GetDisabledReason: self.require(self.itemRangeSelected(self.branchesAreReal)), Description: self.c.Tr.FastForward, Tooltip: self.c.Tr.FastForwardTooltip, }, @@ -655,54 +654,23 @@ func (self *BranchesController) rebase(branch *models.Branch) error { return self.c.Helpers().MergeAndRebase.RebaseOntoRef(branch.Name) } -func (self *BranchesController) fastForward(branch *models.Branch) error { - if !branch.IsTrackingRemote() { +func (self *BranchesController) fastForward(branches []*models.Branch) error { + if !lo.EveryBy(branches, func(branch *models.Branch) bool { return branch.IsTrackingRemote() }) { return errors.New(self.c.Tr.FwdNoUpstream) } - if !branch.RemoteBranchStoredLocally() { + if !lo.EveryBy(branches, func(branch *models.Branch) bool { return branch.RemoteBranchStoredLocally() }) { return errors.New(self.c.Tr.FwdNoLocalUpstream) } - if branch.IsAheadForPull() { + // A branch that is only ahead has nothing to fast-forward to. One that is + // both ahead and behind may still be reset to its upstream, so let the + // helper look into it. + if lo.SomeBy(branches, func(branch *models.Branch) bool { + return branch.IsAheadForPull() && !branch.IsBehindForPull() + }) { return errors.New(self.c.Tr.FwdCommitsToPush) } - action := self.c.Tr.Actions.FastForwardBranch - worktree, ok := self.worktreeForBranch(branch) - - return self.c.WithInlineStatus(branch, types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { - if ok { - self.c.LogAction(action) - - worktreeGitDir := "" - worktreePath := "" - // if it is the current worktree path, no need to specify the path - if !worktree.IsCurrent { - worktreeGitDir = worktree.GitDir - worktreePath = worktree.Path - } - - err := self.c.Git().Sync.Pull( - task, - git_commands.PullOptions{ - RemoteName: branch.UpstreamRemote, - BranchName: branch.UpstreamBranch, - FastForwardOnly: true, - WorktreeGitDir: worktreeGitDir, - WorktreePath: worktreePath, - }, - ) - self.c.RefreshFromWorker(types.RefreshOptions{}) - return err - } - - self.c.LogAction(action) - - err := self.c.Git().Sync.FastForward( - task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch, - ) - self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}}) - return err - }) + return self.c.Helpers().BranchesHelper.FastForwardBranches(branches) } func (self *BranchesController) createTag(branch *models.Branch) error { diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index e87edb460..e3586fa52 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -8,6 +8,7 @@ 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/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" @@ -433,6 +434,200 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool, fe return fetchErr } +// One of the branches that a fast-forward is about to bring to its upstream +type branchToForward struct { + branch *models.Branch + // the worktree that the branch is checked out in, nil if there is none + worktree *models.Worktree + // whether the branch has to be reset to its upstream because it has + // diverged from it, as opposed to being moved forward + reset bool +} + +// Updates the given branches to their upstream branches, fetching those first. +// A branch that is behind its upstream is moved forward to it; one that has +// 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 + toForward := lo.Map(branches, func(branch *models.Branch, _ int) *branchToForward { + worktree, _ := self.worktreeForBranch(branch) + return &branchToForward{branch: branch, worktree: worktree} + }) + anyCheckedOut := lo.SomeBy(toForward, func(f *branchToForward) bool { return f.worktree != nil }) + + // Updating that worktree would move its detached HEAD, which belongs to the + // rebase or bisect, and leave the branch alone + for _, f := range toForward { + if f.worktree != nil && f.worktree.IsRebasingOrBisecting { + return 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 { + defer func() { + if anyCheckedOut { + // The files of those worktrees have changed as well + self.c.RefreshFromWorker(types.RefreshOptions{}) + } else { + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}}) + } + }() + + self.c.LogAction(self.c.Tr.Actions.FastForwardBranch) + + if err := self.fetchUpstreamBranches(task, branches); err != nil { + return err + } + + // Look at all the branches before moving any of them, so that one we + // have to refuse leaves the others alone too + for _, f := range toForward { + if err := self.planForwardingBranch(f); err != nil { + return err + } + } + + return self.forwardBranches(toForward) + }) +} + +// 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 { + for _, branch := range branches[1:] { + self.c.State().SetItemOperation(branch, types.ItemOperationFastForwarding) + } + defer func() { + for _, branch := range branches[1:] { + self.c.State().ClearItemOperation(branch) + } + }() + + return f(task) + }) +} + +func (self *BranchesHelper) fetchUpstreamBranches(task gocui.Task, branches []*models.Branch) error { + remotes := lo.Uniq(lo.Map(branches, func(branch *models.Branch, _ int) string { + return branch.UpstreamRemote + })) + + for _, remote := range remotes { + remoteBranches := []string{} + for _, branch := range branches { + if branch.UpstreamRemote == remote { + remoteBranches = append(remoteBranches, branch.UpstreamBranch) + } + } + + if err := self.c.Git().Sync.FetchRemoteBranches(task, remote, remoteBranches); err != nil { + return err + } + } + + return nil +} + +// Works out whether the branch has to be reset to its upstream, and returns an +// error if it can't be brought there at all. +func (self *BranchesHelper) planForwardingBranch(f *branchToForward) error { + f.reset = !self.c.Git().Branch.IsAncestor( + f.branch.FullRefName(), f.branch.FullUpstreamRefName()) + if !f.reset { + return nil + } + + // Moving the branch to its upstream means giving up the commits it is + // ahead by, so make sure that none of them is ours + hasLocalOnlyCommits, err := self.c.Git().Branch.HasLocalOnlyCommits(f.branch) + if err != nil { + return err + } + if hasLocalOnlyCommits { + return errors.New(utils.ResolvePlaceholderString( + self.c.Tr.FwdLocalOnlyCommits, + map[string]string{"branchName": f.branch.Name}, + )) + } + + if f.worktree != nil { + // Resetting the branch changes the files of the worktree under the + // user's feet, so only do it while they have no changes of their own + // there + worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree) + hasChanges, err := self.c.Git().WorkingTree.HasChangesToTrackedFiles(worktreeGitDir, worktreePath) + if err != nil { + return err + } + if hasChanges { + return errors.New(utils.ResolvePlaceholderString( + self.c.Tr.FwdUncommittedChanges, + map[string]string{"branchName": f.branch.Name}, + )) + } + } + + return nil +} + +func (self *BranchesHelper) forwardBranches(toForward []*branchToForward) error { + // The branches that aren't checked out anywhere are nothing but refs to + // update, so they can all be done in one go + updateCommands := "" + for _, f := range toForward { + if f.worktree == nil { + updateCommands += fmt.Sprintf("update %s %s %s\n", + f.branch.FullRefName(), f.branch.FullUpstreamRefName(), f.branch.CommitHash) + } + } + + if updateCommands != "" { + self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false) + if err := self.c.Git().Branch.UpdateBranchRefs(updateCommands, "lazygit: update to upstream branch"); err != nil { + return err + } + } + + // A branch that is checked out somewhere needs the files of that worktree + // to be updated along with it + for _, f := range toForward { + if f.worktree == nil { + continue + } + + worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree) + + var err error + if f.reset { + err = self.c.Git().WorkingTree.ResetKeep( + f.branch.FullUpstreamRefName(), worktreeGitDir, worktreePath) + } else { + err = self.c.Git().Branch.FastForwardMerge( + f.branch.FullUpstreamRefName(), worktreeGitDir, worktreePath) + } + if err != nil { + return err + } + } + + return nil +} + +// Returns the git dir and the path to pass for the given worktree; both are +// empty for the current one, which git commands use by default anyway. +func (self *BranchesHelper) worktreeArgs(worktree *models.Worktree) (string, string) { + if worktree.IsCurrent { + return "", "" + } + + return worktree.GitDir, worktree.Path +} + func (self *BranchesHelper) AutoForwardBranches(background bool) error { if self.c.UserConfig().Git.AutoForwardBranches == "none" { return nil @@ -463,7 +658,7 @@ func (self *BranchesHelper) AutoForwardBranches(background bool) error { self.c.LogAction(self.c.Tr.Actions.AutoForwardBranches) self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false) - err := self.c.Git().Branch.UpdateBranchRefs(updateCommands) + err := self.c.Git().Branch.UpdateBranchRefs(updateCommands, "lazygit: fast-forward to upstream branch") self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Background: background}) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 8f27efa08..106652ff3 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -525,7 +525,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e } wantFastForward, wantNonFastForward := self.fastForwardMergeUserPreference() - canFastForward := self.c.Git().Branch.CanDoFastForwardMerge(refName) + canFastForward := self.c.Git().Branch.IsAncestor("HEAD", refName) var firstRegularMergeItem *types.MenuItem var secondRegularMergeItem *types.MenuItem diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 79488ed9d..6c96d79c6 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1150,14 +1150,14 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*mode // self.refreshStatus is called at the end of this because that's when we can // be sure there is a State.Model.Branches array to pick the current branch from -func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), waitForRemotes func(), branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { +func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), waitForRemotes func(), branchSelection types.BranchSelectionBehavior, loadExtraInfo bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { loadSeq := self.branchLoadSeq.Add(1) branches, err := env.git.Loaders.BranchLoader.Load( reflogCommits, captured.mainBranches, captured.oldBranches, - loadBehindCounts, + loadExtraInfo, func(f func() error) { self.onWorker(env.background, func(_ gocui.Task) error { err := f() diff --git a/pkg/gui/presentation/branches.go b/pkg/gui/presentation/branches.go index de3a5bb11..75abd640f 100644 --- a/pkg/gui/presentation/branches.go +++ b/pkg/gui/presentation/branches.go @@ -26,6 +26,8 @@ type branchColorPattern struct { var branchColorPatterns []branchColorPattern +var dimYellow = style.FgYellow.SetDim() + func GetBranchListDisplayStrings( branches []*models.Branch, getItemOperation func(item types.HasUrn) types.ItemOperation, @@ -223,7 +225,12 @@ func BranchStatus( } else if branch.RemoteBranchNotStoredLocally() { result = style.FgMagenta.Sprint("?") } else if branch.IsBehindForPull() && branch.IsAheadForPull() { - result = style.FgYellow.Sprintf("↓%s↑%s", branch.BehindForPull, branch.AheadForPull) + // A branch that diverged only because its upstream was rewritten + // has no commits of its own, and fast-forwarding it resolves the + // divergence. Dim it to set it apart from a branch whose + // divergence needs a decision. + divergenceStyle := lo.Ternary(branch.UpstreamRewritten.Load(), dimYellow, style.FgYellow) + result = divergenceStyle.Sprintf("↓%s↑%s", branch.BehindForPull, branch.AheadForPull) } else if branch.IsBehindForPull() { result = style.FgYellow.Sprintf("↓%s", branch.BehindForPull) } else if branch.IsAheadForPull() { diff --git a/pkg/gui/presentation/branches_test.go b/pkg/gui/presentation/branches_test.go index c8fecf140..01c61a0c4 100644 --- a/pkg/gui/presentation/branches_test.go +++ b/pkg/gui/presentation/branches_test.go @@ -457,3 +457,30 @@ func TestGetBranchTextStyle(t *testing.T) { SetCustomBranches(patterns) assert.Equal(t, style.FgRed, GetBranchTextStyle("feature/ISSUE-1")) } + +func TestBranchStatus(t *testing.T) { + oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions) + defer color.ForceSetColorLevel(oldColorLevel) + + c := common.NewDummyCommon() + + divergedBranch := func(upstreamRewritten bool) *models.Branch { + branch := &models.Branch{ + Name: "branch", + UpstreamRemote: "origin", + UpstreamBranch: "branch", + AheadForPull: "3", + BehindForPull: "5", + } + branch.UpstreamRewritten.Store(upstreamRewritten) + return branch + } + + status := func(branch *models.Branch) string { + return BranchStatus(branch, types.ItemOperationNone, c.Tr, time.Time{}, c.UserConfig()) + } + + assert.Equal(t, "\x1b[33m↓5↑3\x1b[0m", status(divergedBranch(false))) + assert.Equal(t, "\x1b[33;2m↓5↑3\x1b[0m", status(divergedBranch(true)), + "a branch whose upstream was rewritten should be dimmed") +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d3847ce32..3cfe7bd78 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -374,6 +374,9 @@ type TranslationSet struct { FwdNoUpstream string FwdNoLocalUpstream string FwdCommitsToPush string + FwdLocalOnlyCommits string + FwdUncommittedChanges string + FwdBranchRebasingOrBisecting string PullRequestNoUpstream string PullRequestChecksPassing string PullRequestChecksPending string @@ -1488,7 +1491,7 @@ func EnglishTranslationSet() *TranslationSet { ToggleStagingViewTooltip: "Switch to other view (staged/unstaged changes).", ReturnToFilesPanel: `Return to files panel`, FastForward: `Fast-forward`, - FastForwardTooltip: "Fast-forward selected branch from its upstream.", + FastForwardTooltip: "Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates).", FastForwarding: "Fast-forwarding", FoundConflictsTitle: "Conflicts!", ViewConflictsMenuItem: "View conflicts", @@ -1547,6 +1550,9 @@ func EnglishTranslationSet() *TranslationSet { FwdNoUpstream: "Cannot fast-forward a branch with no upstream", FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally", FwdCommitsToPush: "Cannot fast-forward a branch with commits to push", + FwdLocalOnlyCommits: "Cannot fast-forward '{{.branchName}}' because it has commits which were never on its remote branch", + FwdUncommittedChanges: "Cannot fast-forward '{{.branchName}}' because the worktree it is checked out in has uncommitted changes", + FwdBranchRebasingOrBisecting: "Cannot fast-forward '{{.branchName}}' because it is being rebased or bisected in worktree {{.worktreeName}}", PullRequestNoUpstream: "Cannot open a pull request for a branch with no upstream", PullRequestChecksPassing: "Passing", PullRequestChecksPending: "Pending", diff --git a/pkg/integration/tests/sync/fast_forward_behind_branch.go b/pkg/integration/tests/sync/fast_forward_behind_branch.go new file mode 100644 index 000000000..9b767c4de --- /dev/null +++ b/pkg/integration/tests/sync/fast_forward_behind_branch.go @@ -0,0 +1,49 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FastForwardBehindBranch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Fast-forward a branch that is behind its upstream branch, without checking it out", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one") + shell.NewBranch("feature") + shell.EmptyCommit("two") + shell.EmptyCommit("three") + + shell.CloneIntoRemote("origin") + shell.SetBranchUpstream("feature", "origin/feature") + + // remove the two commits so that there is something to fast-forward to + shell.HardReset("HEAD~2") + shell.Checkout("master") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + Contains("feature ↓2"), + ). + SelectNextItem(). + Press(keys.Branches.FastForward). + Lines( + Contains("master"), + Contains("feature ✓").IsSelected(), + ). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + Lines( + Contains("three"), + Contains("two"), + Contains("one"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fast_forward_behind_branch_checked_out.go b/pkg/integration/tests/sync/fast_forward_behind_branch_checked_out.go new file mode 100644 index 000000000..57d566a19 --- /dev/null +++ b/pkg/integration/tests/sync/fast_forward_behind_branch_checked_out.go @@ -0,0 +1,57 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FastForwardBehindBranchCheckedOut = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Fast-forward the checked out branch when it is behind its upstream branch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file", "content") + shell.Commit("one") + shell.EmptyCommit("two") + shell.EmptyCommit("three") + + shell.CloneIntoRemote("origin") + shell.SetBranchUpstream("master", "origin/master") + + // remove the two commits so that there is something to fast-forward to + shell.HardReset("HEAD~2") + + // a change that the fast-forward doesn't get in the way of + shell.UpdateFile("file", "changed") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Lines( + Contains("one"), + ) + + t.Views().Branches(). + Focus(). + Lines( + Contains("master ↓2").IsSelected(), + ). + Press(keys.Branches.FastForward). + Lines( + Contains("master ✓").IsSelected(), + ) + + t.Views().Commits(). + Lines( + Contains("three"), + Contains("two"), + Contains("one"), + ) + + // Moving the branch forward doesn't touch the change + t.Views().Files(). + Lines( + Contains("file"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fast_forward_behind_branch_without_reflogs.go b/pkg/integration/tests/sync/fast_forward_behind_branch_without_reflogs.go new file mode 100644 index 000000000..364b95be0 --- /dev/null +++ b/pkg/integration/tests/sync/fast_forward_behind_branch_without_reflogs.go @@ -0,0 +1,44 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FastForwardBehindBranchWithoutReflogs = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Fast-forward a branch that is behind its upstream branch in a repo that keeps no reflogs", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + // This is the default in a bare repository, so it is what people with a + // bare repo and linked worktrees work with + shell.SetConfig("core.logAllRefUpdates", "false") + + shell.EmptyCommit("one") + shell.NewBranch("feature") + shell.EmptyCommit("two") + shell.EmptyCommit("three") + + shell.CloneIntoRemote("origin") + shell.SetBranchUpstream("feature", "origin/feature") + + // remove the two commits so that there is something to fast-forward to + shell.HardReset("HEAD~2") + shell.Checkout("master") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + Contains("feature ↓2"), + ). + SelectNextItem(). + Press(keys.Branches.FastForward). + Lines( + Contains("master"), + Contains("feature ✓").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fast_forward_branch_being_rebased_in_worktree.go b/pkg/integration/tests/sync/fast_forward_branch_being_rebased_in_worktree.go new file mode 100644 index 000000000..dabcc5dac --- /dev/null +++ b/pkg/integration/tests/sync/fast_forward_branch_being_rebased_in_worktree.go @@ -0,0 +1,62 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FastForwardBranchBeingRebasedInWorktree = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Try to fast-forward a branch that is being rebased in another worktree", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file", "content") + shell.Commit("one") + shell.EmptyCommit("two") + shell.EmptyCommit("three") + + shell.CloneIntoRemote("origin") + shell.SetBranchUpstream("master", "origin/master") + + // remove a commit so that there is something to fast-forward to + shell.HardReset("HEAD~1") + + shell.NewBranch("other") + shell.AddWorktreeCheckout("master", "../linked-worktree") + + // the failing exec stops the rebase after picking "two", with HEAD + // detached from master + shell.RunCommandExpectError([]string{"git", "-C", "../linked-worktree", "rebase", "--exec", "false", "HEAD~1"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("other").IsSelected(), + Contains("master (worktree linked-worktree) ↓1"), + ). + NavigateToLine(Contains("master")). + Press(keys.Branches.FastForward) + + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Equals("Cannot fast-forward 'master' because it is being rebased or bisected in worktree linked-worktree")). + Confirm() + + t.Views().Worktrees(). + Focus(). + NavigateToLine(Contains("linked-worktree")). + Press(keys.Universal.Select) + + t.Views().Information().Content(Contains("Rebasing")) + + t.Views().Commits(). + Lines( + Contains("─── Pending rebase todos"), + Contains("─── Commits"), + Contains("two"), + Contains("one"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fast_forward_rewritten_branch.go b/pkg/integration/tests/sync/fast_forward_rewritten_branch.go new file mode 100644 index 000000000..0d1fd2860 --- /dev/null +++ b/pkg/integration/tests/sync/fast_forward_rewritten_branch.go @@ -0,0 +1,39 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FastForwardRewrittenBranch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Fast-forward a branch that has diverged from its upstream because the upstream branch was rewritten", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + createBranchRewrittenOnTheRemote(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + Contains("feature ↓2↑2"), + ). + SelectNextItem(). + Press(keys.Branches.FastForward). + Lines( + Contains("master"), + Contains("feature ✓").IsSelected(), + ). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + Lines( + Contains("three-rewritten"), + Contains("two-rewritten"), + Contains("one"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fast_forward_rewritten_branch_checked_out.go b/pkg/integration/tests/sync/fast_forward_rewritten_branch_checked_out.go new file mode 100644 index 000000000..29c4404a6 --- /dev/null +++ b/pkg/integration/tests/sync/fast_forward_rewritten_branch_checked_out.go @@ -0,0 +1,45 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FastForwardRewrittenBranchCheckedOut = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Fast-forward the checked out branch after its upstream branch was rewritten", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + createBranchRewrittenOnTheRemote(shell) + + shell.Checkout("feature") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Lines( + Contains("three"), + Contains("two"), + Contains("one"), + ) + + t.Views().Branches(). + Focus(). + Lines( + Contains("feature ↓2↑2").IsSelected(), + Contains("master"), + ). + Press(keys.Branches.FastForward). + Lines( + Contains("feature ✓").IsSelected(), + Contains("master"), + ) + + t.Views().Commits(). + Lines( + Contains("three-rewritten"), + Contains("two-rewritten"), + Contains("one"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fast_forward_rewritten_branch_with_local_commits.go b/pkg/integration/tests/sync/fast_forward_rewritten_branch_with_local_commits.go new file mode 100644 index 000000000..5d849e9bd --- /dev/null +++ b/pkg/integration/tests/sync/fast_forward_rewritten_branch_with_local_commits.go @@ -0,0 +1,40 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FastForwardRewrittenBranchWithLocalCommits = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Try to fast-forward a branch that has a commit of its own on top of a rewritten upstream branch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + createBranchRewrittenOnTheRemote(shell) + + shell.Checkout("feature") + shell.EmptyCommit("mine") + shell.Checkout("master") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + Contains("feature ↓2↑3"), + ). + SelectNextItem(). + Press(keys.Branches.FastForward) + + t.ExpectPopup().Alert().Title(Equals("Error")). + Content(Contains("Cannot fast-forward 'feature' because it has commits")). + Confirm() + + t.Views().Branches(). + Lines( + Contains("master"), + Contains("feature ↓2↑3").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fast_forward_rewritten_branch_with_stale_submodule.go b/pkg/integration/tests/sync/fast_forward_rewritten_branch_with_stale_submodule.go new file mode 100644 index 000000000..5b3724a2d --- /dev/null +++ b/pkg/integration/tests/sync/fast_forward_rewritten_branch_with_stale_submodule.go @@ -0,0 +1,45 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FastForwardRewrittenBranchWithStaleSubmodule = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Fast-forward a branch with a rewritten upstream branch while its worktree has a submodule checked out at a different commit than the one recorded", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial") + shell.CloneIntoSubmodule("sub", "sub") + createBranchRewrittenOnTheRemote(shell) + + shell.Checkout("feature") + shell.RunCommand([]string{"git", "-C", "sub", "commit", "--allow-empty", "-m", "newer"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Lines( + Contains("sub"), + ) + + t.Views().Branches(). + Focus(). + Lines( + Contains("feature ↓2↑2").IsSelected(), + Contains("master"), + ). + Press(keys.Branches.FastForward). + Lines( + Contains("feature ✓").IsSelected(), + Contains("master"), + ) + + // The submodule was left alone + t.Views().Files(). + Lines( + Contains("sub"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fast_forward_rewritten_branch_with_uncommitted_changes.go b/pkg/integration/tests/sync/fast_forward_rewritten_branch_with_uncommitted_changes.go new file mode 100644 index 000000000..25918c785 --- /dev/null +++ b/pkg/integration/tests/sync/fast_forward_rewritten_branch_with_uncommitted_changes.go @@ -0,0 +1,44 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FastForwardRewrittenBranchWithUncommittedChanges = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Try to fast-forward a branch with a rewritten upstream branch while its worktree has uncommitted changes", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file", "content") + createBranchRewrittenOnTheRemote(shell) + + shell.Checkout("feature") + shell.UpdateFile("file", "changed") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("feature ↓2↑2").IsSelected(), + Contains("master"), + ). + Press(keys.Branches.FastForward) + + t.ExpectPopup().Alert().Title(Equals("Error")). + Content(Contains("Cannot fast-forward 'feature' because the worktree")). + Confirm() + + t.Views().Branches(). + Lines( + Contains("feature ↓2↑2").IsSelected(), + Contains("master"), + ) + + t.Views().Files(). + Lines( + Contains("file"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fast_forward_rewritten_branch_without_reflogs.go b/pkg/integration/tests/sync/fast_forward_rewritten_branch_without_reflogs.go new file mode 100644 index 000000000..11092ef56 --- /dev/null +++ b/pkg/integration/tests/sync/fast_forward_rewritten_branch_without_reflogs.go @@ -0,0 +1,41 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FastForwardRewrittenBranchWithoutReflogs = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Try to fast-forward a branch with a rewritten upstream branch in a repo that keeps no reflogs", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + // Without reflogs there is no record of the values the remote-tracking + // branch had before, so lazygit can't tell whether the commits that + // the branch is ahead by were ever on the remote branch + shell.SetConfig("core.logAllRefUpdates", "false") + + createBranchRewrittenOnTheRemote(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + Contains("feature ↓2↑2"), + ). + SelectNextItem(). + Press(keys.Branches.FastForward) + + t.ExpectPopup().Alert().Title(Equals("Error")). + Content(Contains("Cannot fast-forward 'feature' because it has commits")). + Confirm() + + t.Views().Branches(). + Lines( + Contains("master"), + Contains("feature ↓2↑2").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fast_forward_rewritten_stack_of_branches.go b/pkg/integration/tests/sync/fast_forward_rewritten_stack_of_branches.go new file mode 100644 index 000000000..64d4928c2 --- /dev/null +++ b/pkg/integration/tests/sync/fast_forward_rewritten_stack_of_branches.go @@ -0,0 +1,45 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FastForwardRewrittenStackOfBranches = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Fast-forward a whole stack of branches whose upstream branches were rewritten", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical" + }, + SetupRepo: func(shell *Shell) { + createStackRewrittenOnTheRemote(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + Contains("branch1 ↓1↑1"), + Contains("branch2 ↓2↑2"), + Contains("branch3 ↓3↑3"), + ). + SelectNextItem(). + Press(keys.Universal.ToggleRangeSelect). + SelectNextItem(). + SelectNextItem(). + Lines( + Contains("master"), + Contains("branch1 ↓1↑1").IsSelected(), + Contains("branch2 ↓2↑2").IsSelected(), + Contains("branch3 ↓3↑3").IsSelected(), + ). + Press(keys.Branches.FastForward). + Lines( + Contains("master"), + Contains("branch1 ✓").IsSelected(), + Contains("branch2 ✓").IsSelected(), + Contains("branch3 ✓").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fast_forward_rewritten_stack_of_branches_with_local_commits.go b/pkg/integration/tests/sync/fast_forward_rewritten_stack_of_branches_with_local_commits.go new file mode 100644 index 000000000..2b68155cb --- /dev/null +++ b/pkg/integration/tests/sync/fast_forward_rewritten_stack_of_branches_with_local_commits.go @@ -0,0 +1,50 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FastForwardRewrittenStackOfBranchesWithLocalCommits = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Try to fast-forward a stack of branches of which one has a commit of its own", + 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("master") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + Contains("branch1 ↓1↑1"), + Contains("branch2 ↓2↑3"), + Contains("branch3 ↓3↑3"), + ). + SelectNextItem(). + Press(keys.Universal.ToggleRangeSelect). + SelectNextItem(). + SelectNextItem(). + Press(keys.Branches.FastForward) + + t.ExpectPopup().Alert().Title(Equals("Error")). + Content(Contains("Cannot fast-forward 'branch2' because it has commits")). + Confirm() + + // None of them was touched, not even the ones we could have forwarded + t.Views().Branches(). + Lines( + Contains("master"), + Contains("branch1 ↓1↑1").IsSelected(), + Contains("branch2 ↓2↑3").IsSelected(), + Contains("branch3 ↓3↑3").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/sync/shared.go b/pkg/integration/tests/sync/shared.go index 6b1f9065e..d753bbd45 100644 --- a/pkg/integration/tests/sync/shared.go +++ b/pkg/integration/tests/sync/shared.go @@ -55,6 +55,72 @@ func createRebasedStackOfBranches(shell *Shell) { shell.EmptyCommit("three-rebased") } +// Creates the branch "feature" with two commits on top of master, pushes it, +// and then rewrites those two commits and force-pushes them the way somebody +// else rebasing the branch would. The local branch stays where it was, so it +// has diverged from its remote branch without having any commits of its own. +// Leaves master checked out. +func createBranchRewrittenOnTheRemote(shell *Shell) { + shell.EmptyCommit("one") + shell.NewBranch("feature") + shell.EmptyCommit("two") + shell.EmptyCommit("three") + + shell.CloneIntoRemote("origin") + shell.SetBranchUpstream("feature", "origin/feature") + + shell.CreateLightweightTag("before-rewrite", "feature") + shell.HardReset("master") + shell.EmptyCommit("two-rewritten") + shell.EmptyCommit("three-rewritten") + shell.RunCommand([]string{"git", "push", "--force", "origin", "feature"}) + shell.HardReset("before-rewrite") + shell.RunCommand([]string{"git", "tag", "-d", "before-rewrite"}) + + shell.Checkout("master") +} + +// Creates the branches branch1, branch2 and branch3, each on top of the +// previous one and pushed to origin, and then rewrites their commits and +// force-pushes them the way somebody else rebasing the stack would. The local +// branches stay where they were, so all of them have diverged from their +// remote branches without having any commits of their own. Leaves master +// checked out. +func createStackRewrittenOnTheRemote(shell *Shell) { + shell.EmptyCommit("base") + shell.NewBranch("branch1") + shell.EmptyCommit("one") + shell.NewBranch("branch2") + shell.EmptyCommit("two") + shell.NewBranch("branch3") + shell.EmptyCommit("three") + + shell.CloneIntoRemote("origin") + shell.SetBranchUpstream("branch1", "origin/branch1") + shell.SetBranchUpstream("branch2", "origin/branch2") + shell.SetBranchUpstream("branch3", "origin/branch3") + + shell.CreateLightweightTag("before-rewrite", "branch3") + shell.Checkout("branch1") + shell.HardReset("master") + shell.EmptyCommit("one-rewritten") + shell.Checkout("branch2") + shell.HardReset("branch1") + shell.EmptyCommit("two-rewritten") + shell.Checkout("branch3") + shell.HardReset("branch2") + shell.EmptyCommit("three-rewritten") + shell.RunCommand([]string{"git", "push", "--force", "origin", "branch1", "branch2", "branch3"}) + + // Put the local branches back where they were + shell.HardReset("before-rewrite") + shell.RunCommand([]string{"git", "branch", "-f", "branch2", "before-rewrite~"}) + shell.RunCommand([]string{"git", "branch", "-f", "branch1", "before-rewrite~2"}) + shell.RunCommand([]string{"git", "tag", "-d", "before-rewrite"}) + + shell.Checkout("master") +} + func assertSuccessfullyPushed(t *TestDriver) { t.Views().Status().Content(Equals("✓ repo → master")) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 802993d31..6f9aa5e7b 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -474,6 +474,18 @@ var tests = []*components.IntegrationTest{ submodule.Stage, submodule.StageAllWithDirtySubmodule, submodule.StageDirtyOnly, + sync.FastForwardBehindBranch, + sync.FastForwardBehindBranchCheckedOut, + sync.FastForwardBehindBranchWithoutReflogs, + sync.FastForwardBranchBeingRebasedInWorktree, + sync.FastForwardRewrittenBranch, + sync.FastForwardRewrittenBranchCheckedOut, + sync.FastForwardRewrittenBranchWithLocalCommits, + sync.FastForwardRewrittenBranchWithStaleSubmodule, + sync.FastForwardRewrittenBranchWithUncommittedChanges, + sync.FastForwardRewrittenBranchWithoutReflogs, + sync.FastForwardRewrittenStackOfBranches, + sync.FastForwardRewrittenStackOfBranchesWithLocalCommits, sync.FetchAndAutoForwardBranchesAllBranches, sync.FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree, sync.FetchAndAutoForwardBranchesNone,