From 173001c3e7c23a7521ddba292619fe8a6455cc30 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 23 Sep 2026 13:21:32 +0200 Subject: [PATCH 01/11] Add a function to tell whether a branch has commits of its own A branch that has diverged from its upstream doesn't necessarily hold any work 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 every one of them was on the remote branch at some point. A later commit offers to reset such a branch to its upstream, and for that it has to tell this case apart from a branch that holds commits created here. 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 its current value or in one of those. HasLocalOnlyCommits asks git for a commit of the branch that none of them contains. Two details of the reflog are worth knowing. The value the ref had before its oldest entry is that entry's old value, and @{} is the only way to name it. It doesn't exist when the oldest entry is the one that created the ref, and asking for it then is an error, not an empty result. Reflogs can also be missing altogether, because core.logAllRefUpdates defaults to false in a bare repository. Neither case must stop us from recognizing a branch that is strictly behind its upstream, so the current value of the remote-tracking branch is always used; missing previous values only make the answer more conservative. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/branch.go | 72 +++++++++++++++ pkg/commands/git_commands/branch_test.go | 109 +++++++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index a55278b5b..017b87ed4 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -353,6 +353,78 @@ func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches * return stdout == "", nil } +// 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 +} + func (self *BranchCommands) UpdateBranchRefs(updateCommands string) error { cmdArgs := NewGitCmd("update-ref"). Arg("--stdin"). 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() + }) + } +} From 349bcc3691b7e5ee651129940e5550e78aaafccf Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 23 Sep 2026 13:39:56 +0200 Subject: [PATCH 02/11] Rename the loadBehindCounts parameter to loadExtraInfo The flag says whether this load is the one that kicks off the values determined in the background afterwards. A later commit adds a second of those next to the behind-counts, so name the flag after what it controls rather than after its only user so far. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/branch_loader.go | 4 ++-- pkg/gui/controllers/helpers/refresh_helper.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index e4ccc6ab7..10d9ad30c 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -67,7 +67,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) { @@ -144,7 +144,7 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit, } } - if loadBehindCounts && self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" { + if loadExtraInfo && self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" { onWorker(func() error { return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc) }) 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() From 3ff0fdbcc10a2f7c0203455057ab5f05c97495bf Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 23 Sep 2026 13:40:20 +0200 Subject: [PATCH 03/11] Dim the divergence of a branch whose upstream was rewritten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When somebody else rebases a branch and force-pushes it, our branch shows up as diverged, say ↓5↑3, and that looks exactly like a branch that carries three commits of our own. The two want different treatment. The first one only has to be moved to its upstream, while the second one needs a decision about what to do with those commits. The panel doesn't tell them apart, so users have to look at each branch in turn to find out which one they are dealing with. Determine for every diverged branch whether it has commits of its own, and dim its divergence when it doesn't. This runs on a worker, as it takes a few git commands per diverged branch, and a branch keeps the answer from the previous load while a new one is in flight, so that the display doesn't flicker. A later commit teaches the fast-forward command to act on such a branch. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git.go | 2 +- pkg/commands/git_commands/branch_loader.go | 50 +++++++++++++-- .../git_commands/branch_loader_test.go | 61 +++++++++++++++++++ pkg/commands/models/branch.go | 7 +++ pkg/gui/presentation/branches.go | 9 ++- pkg/gui/presentation/branches_test.go | 27 ++++++++ 6 files changed, 150 insertions(+), 6 deletions(-) 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_loader.go b/pkg/commands/git_commands/branch_loader.go index 10d9ad30c..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, } } @@ -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 loadExtraInfo && 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/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/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") +} From 503ca22d09679f0601d78f2a22483eecfc8202ef Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 23 Sep 2026 13:43:12 +0200 Subject: [PATCH 04/11] Generalize CanDoFastForwardMerge into IsAncestor The function asks git whether HEAD is an ancestor of a ref, and its name says what the one caller wants to know. A later commit asks the same question about a branch that isn't checked out, so let the caller name both refs. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/branch.go | 7 ++++--- pkg/gui/controllers/helpers/merge_and_rebase_helper.go | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index 017b87ed4..8f5fb2d8c 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -285,11 +285,12 @@ 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 { +// 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 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 From 0d8b18cfb77be6e372c675c253b14815d17b403d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 23 Sep 2026 14:24:53 +0200 Subject: [PATCH 05/11] Move fast-forwarding a branch into BranchesHelper Most of the logic of this controller belongs in a helper, and the next commit reshapes it, so move it over unchanged first to keep that diff about the change itself. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/branches_controller.go | 39 +----------------- .../controllers/helpers/branches_helper.go | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index f01cf2c0b..cf613430e 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" @@ -666,43 +665,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { 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.FastForwardBranch(branch) } 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..75cb788ca 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,46 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool, fe return fetchErr } +func (self *BranchesHelper) FastForwardBranch(branch *models.Branch) error { + 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 + }) +} + func (self *BranchesHelper) AutoForwardBranches(background bool) error { if self.c.UserConfig().Git.AutoForwardBranches == "none" { return nil From 667df9e78490958455c1a6c382be9830ff711069 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 23 Sep 2026 13:49:02 +0200 Subject: [PATCH 06/11] Split fast-forwarding a branch into fetching and updating it Fast-forwarding a branch that isn't checked out ran a single `git fetch refs/heads/:`, so git's own refusal to move a local branch backwards served as the safety check. A later commit widens the set of branches the command accepts, and for that lazygit has to make that decision itself. Fetch the remote branch on its own, updating only its remote-tracking branch. Then ask git whether the branch is an ancestor of it, and only then move the branch: with `git update-ref` when it isn't checked out anywhere, guarded by the hash we knew it to have, and with `git merge --ff-only` in its worktree when it is checked out there. Keeping `git pull --ff-only` for the latter case would fetch a second time. Fast-forwarding a branch that isn't checked out in a worktree had no integration test at all, so add one. The second new test covers a repo that keeps no reflogs, because a later commit starts reading those and this case has to keep working without them. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/branch.go | 14 +++++ pkg/commands/git_commands/sync.go | 13 ++++- .../controllers/helpers/branches_helper.go | 53 +++++++++-------- .../tests/sync/fast_forward_behind_branch.go | 49 ++++++++++++++++ .../fast_forward_behind_branch_checked_out.go | 57 +++++++++++++++++++ ...t_forward_behind_branch_without_reflogs.go | 44 ++++++++++++++ pkg/integration/tests/test_list.go | 3 + 7 files changed, 207 insertions(+), 26 deletions(-) create mode 100644 pkg/integration/tests/sync/fast_forward_behind_branch.go create mode 100644 pkg/integration/tests/sync/fast_forward_behind_branch_checked_out.go create mode 100644 pkg/integration/tests/sync/fast_forward_behind_branch_without_reflogs.go diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index 8f5fb2d8c..6d060613a 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -285,6 +285,20 @@ func (self *BranchCommands) Merge(branchName string, variant MergeVariant) error return self.cmd.New(cmdArgs).Run() } +// 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 { diff --git a/pkg/commands/git_commands/sync.go b/pkg/commands/git_commands/sync.go index 1a20a3293..c11846c8d 100644 --- a/pkg/commands/git_commands/sync.go +++ b/pkg/commands/git_commands/sync.go @@ -1,6 +1,8 @@ package git_commands import ( + "fmt" + "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/gocui" @@ -111,15 +113,20 @@ 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 branch of the given remote, updating its remote-tracking +// branch. Local branches are left alone, including the one that tracks it. +func (self *SyncCommands) FetchRemoteBranch( task gocui.Task, - branchName string, remoteName string, remoteBranchName string, ) error { cmdArgs := self.fetchCommandBuilder(false). Arg(remoteName). - Arg("refs/heads/" + remoteBranchName + ":" + branchName). + // The explicit destination and the leading + make sure that the + // remote-tracking branch is updated even when the remote branch was + // rewritten, whatever the remote's fetch refspec says + Arg(fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s", + remoteBranchName, remoteName, remoteBranchName)). ToArgv() return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 75cb788ca..feb513736 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -434,14 +434,34 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool, fe return fetchErr } +// Updates the given branch to its upstream branch, fetching that first. The +// branch must not have any commits that its upstream doesn't have, so that +// nothing is lost. func (self *BranchesHelper) FastForwardBranch(branch *models.Branch) error { - action := self.c.Tr.Actions.FastForwardBranch - worktree, ok := self.worktreeForBranch(branch) + worktree, checkedOut := 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) + defer func() { + if checkedOut { + // The files of that worktree 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) + + err := self.c.Git().Sync.FetchRemoteBranch(task, branch.UpstreamRemote, branch.UpstreamBranch) + if err != nil { + return err + } + + if !self.c.Git().Branch.IsAncestor(branch.FullRefName(), branch.FullUpstreamRefName()) { + return errors.New(self.c.Tr.FwdCommitsToPush) + } + + if checkedOut { worktreeGitDir := "" worktreePath := "" // if it is the current worktree path, no need to specify the path @@ -450,27 +470,14 @@ func (self *BranchesHelper) FastForwardBranch(branch *models.Branch) error { 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 + return self.c.Git().Branch.FastForwardMerge( + branch.FullUpstreamRefName(), worktreeGitDir, worktreePath) } - 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 + updateCommand := fmt.Sprintf("update %s %s %s", + branch.FullRefName(), branch.FullUpstreamRefName(), branch.CommitHash) + self.c.LogCommand(updateCommand, false) + return self.c.Git().Branch.UpdateBranchRefs(updateCommand + "\n") }) } 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/test_list.go b/pkg/integration/tests/test_list.go index 802993d31..3f7101ab2 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -474,6 +474,9 @@ var tests = []*components.IntegrationTest{ submodule.Stage, submodule.StageAllWithDirtySubmodule, submodule.StageDirtyOnly, + sync.FastForwardBehindBranch, + sync.FastForwardBehindBranchCheckedOut, + sync.FastForwardBehindBranchWithoutReflogs, sync.FetchAndAutoForwardBranchesAllBranches, sync.FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree, sync.FetchAndAutoForwardBranchesNone, From 78dc65cbeb0a1b7685a111b2a6ba9d14afcd7339 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 23 Sep 2026 13:56:36 +0200 Subject: [PATCH 07/11] Fast-forward branches whose upstream branch was rewritten When somebody else rebases a branch and force-pushes it, the local branch is left diverged from it, so `f` refuses to touch it. To get back in sync, the branch has to be checked out and pulled, and for a stack of branches that means doing it once per branch. Such a branch has none of its own work in it, though: the commits it is ahead by are the old versions of the ones that are now on the remote branch. So when the branch has diverged and none of the commits it is ahead by is ours, reset it to its upstream instead of refusing. This is the same state that checking the branch out and pulling it would produce, as rebase drops commits that the upstream already has. A branch that isn't checked out anywhere is reset with the same `git update-ref` that a fast-forward uses. One that is checked out somewhere is reset with `git reset --keep`, and only when that worktree has no changes to tracked files, so that the files under the user's feet change no more than they have to. `--keep` also refuses to overwrite an untracked file that the reset would bring in, which `--hard` would do silently. Co-Authored-By: Claude Opus 5 (1M context) --- docs-master/Stacked_Branches.md | 12 ++++ docs-master/keybindings/Keybindings_en.md | 2 +- docs-master/keybindings/Keybindings_ko.md | 2 +- docs-master/keybindings/Keybindings_nl.md | 2 +- docs-master/keybindings/Keybindings_ru.md | 2 +- pkg/commands/git_commands/working_tree.go | 36 ++++++++++ pkg/gui/controllers/branches_controller.go | 5 +- .../controllers/helpers/branches_helper.go | 67 +++++++++++++++---- pkg/i18n/english.go | 6 +- .../sync/fast_forward_rewritten_branch.go | 39 +++++++++++ ...st_forward_rewritten_branch_checked_out.go | 45 +++++++++++++ ...ard_rewritten_branch_with_local_commits.go | 40 +++++++++++ ...d_rewritten_branch_with_stale_submodule.go | 45 +++++++++++++ ...written_branch_with_uncommitted_changes.go | 44 ++++++++++++ ...orward_rewritten_branch_without_reflogs.go | 41 ++++++++++++ pkg/integration/tests/sync/shared.go | 25 +++++++ pkg/integration/tests/test_list.go | 6 ++ 17 files changed, 399 insertions(+), 20 deletions(-) create mode 100644 pkg/integration/tests/sync/fast_forward_rewritten_branch.go create mode 100644 pkg/integration/tests/sync/fast_forward_rewritten_branch_checked_out.go create mode 100644 pkg/integration/tests/sync/fast_forward_rewritten_branch_with_local_commits.go create mode 100644 pkg/integration/tests/sync/fast_forward_rewritten_branch_with_stale_submodule.go create mode 100644 pkg/integration/tests/sync/fast_forward_rewritten_branch_with_uncommitted_changes.go create mode 100644 pkg/integration/tests/sync/fast_forward_rewritten_branch_without_reflogs.go diff --git a/docs-master/Stacked_Branches.md b/docs-master/Stacked_Branches.md index 84a11507e..426fc9203 100644 --- a/docs-master/Stacked_Branches.md +++ b/docs-master/Stacked_Branches.md @@ -24,3 +24,15 @@ 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. 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_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/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index cf613430e..8d51fa09b 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -661,7 +661,10 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { if !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 branch.IsAheadForPull() && !branch.IsBehindForPull() { return errors.New(self.c.Tr.FwdCommitsToPush) } diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index feb513736..9e010786c 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -435,8 +435,8 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool, fe } // Updates the given branch to its upstream branch, fetching that first. The -// branch must not have any commits that its upstream doesn't have, so that -// nothing is lost. +// branch is moved forward if it is behind its upstream, and reset to it if it +// has diverged without having any commits of its own. func (self *BranchesHelper) FastForwardBranch(branch *models.Branch) error { worktree, checkedOut := self.worktreeForBranch(branch) @@ -457,21 +457,25 @@ func (self *BranchesHelper) FastForwardBranch(branch *models.Branch) error { return err } - if !self.c.Git().Branch.IsAncestor(branch.FullRefName(), branch.FullUpstreamRefName()) { - return errors.New(self.c.Tr.FwdCommitsToPush) + isFastForward := self.c.Git().Branch.IsAncestor( + branch.FullRefName(), branch.FullUpstreamRefName()) + if !isFastForward { + // 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(branch) + if err != nil { + return err + } + if hasLocalOnlyCommits { + return errors.New(utils.ResolvePlaceholderString( + self.c.Tr.FwdLocalOnlyCommits, + map[string]string{"branchName": branch.Name}, + )) + } } if checkedOut { - worktreeGitDir := "" - worktreePath := "" - // if it is the current worktree path, no need to specify the path - if !worktree.IsCurrent { - worktreeGitDir = worktree.GitDir - worktreePath = worktree.Path - } - - return self.c.Git().Branch.FastForwardMerge( - branch.FullUpstreamRefName(), worktreeGitDir, worktreePath) + return self.forwardCheckedOutBranch(branch, worktree, isFastForward) } updateCommand := fmt.Sprintf("update %s %s %s", @@ -481,6 +485,41 @@ func (self *BranchesHelper) FastForwardBranch(branch *models.Branch) error { }) } +// Updates a branch that is checked out in the given worktree, which needs the +// files there to be updated along with it. +func (self *BranchesHelper) forwardCheckedOutBranch( + branch *models.Branch, worktree *models.Worktree, isFastForward bool, +) error { + worktreeGitDir := "" + worktreePath := "" + // if it is the current worktree path, no need to specify the path + if !worktree.IsCurrent { + worktreeGitDir = worktree.GitDir + worktreePath = worktree.Path + } + + if isFastForward { + return self.c.Git().Branch.FastForwardMerge( + branch.FullUpstreamRefName(), worktreeGitDir, worktreePath) + } + + // 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 + 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": branch.Name}, + )) + } + + return self.c.Git().WorkingTree.ResetKeep( + branch.FullUpstreamRefName(), worktreeGitDir, worktreePath) +} + func (self *BranchesHelper) AutoForwardBranches(background bool) error { if self.c.UserConfig().Git.AutoForwardBranches == "none" { return nil diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d3847ce32..0a324a14a 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -374,6 +374,8 @@ type TranslationSet struct { FwdNoUpstream string FwdNoLocalUpstream string FwdCommitsToPush string + FwdLocalOnlyCommits string + FwdUncommittedChanges string PullRequestNoUpstream string PullRequestChecksPassing string PullRequestChecksPending string @@ -1488,7 +1490,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 +1549,8 @@ 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", 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_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/shared.go b/pkg/integration/tests/sync/shared.go index 6b1f9065e..5b144e328 100644 --- a/pkg/integration/tests/sync/shared.go +++ b/pkg/integration/tests/sync/shared.go @@ -55,6 +55,31 @@ 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") +} + 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 3f7101ab2..9ccee7912 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -477,6 +477,12 @@ var tests = []*components.IntegrationTest{ sync.FastForwardBehindBranch, sync.FastForwardBehindBranchCheckedOut, sync.FastForwardBehindBranchWithoutReflogs, + sync.FastForwardRewrittenBranch, + sync.FastForwardRewrittenBranchCheckedOut, + sync.FastForwardRewrittenBranchWithLocalCommits, + sync.FastForwardRewrittenBranchWithStaleSubmodule, + sync.FastForwardRewrittenBranchWithUncommittedChanges, + sync.FastForwardRewrittenBranchWithoutReflogs, sync.FetchAndAutoForwardBranchesAllBranches, sync.FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree, sync.FetchAndAutoForwardBranchesNone, From 83bff569a0e4443db84c50ab9f165381d30a26d8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 23 Sep 2026 14:02:32 +0200 Subject: [PATCH 08/11] Allow fast-forwarding a range of branches After somebody else rebased and force-pushed a stack of branches, every branch of the stack has to be brought back to its upstream, and pressing `f` on them one by one is as tedious as the checking out and pulling it replaces. Let `f` work on a range selection. The upstream branches are fetched with one `git fetch` per remote, and the branches that aren't checked out anywhere move in a single `git update-ref` call. All the selected branches are looked at before any of them is moved, so a branch that has to be refused leaves the others alone rather than updating the stack halfway. Co-Authored-By: Claude Opus 5 (1M context) --- docs-master/Stacked_Branches.md | 5 + pkg/commands/git_commands/sync.go | 24 ++- pkg/gui/controllers/branches_controller.go | 16 +- .../controllers/helpers/branches_helper.go | 203 +++++++++++++----- ...ast_forward_rewritten_stack_of_branches.go | 45 ++++ ...en_stack_of_branches_with_local_commits.go | 50 +++++ pkg/integration/tests/sync/shared.go | 41 ++++ pkg/integration/tests/test_list.go | 2 + 8 files changed, 317 insertions(+), 69 deletions(-) create mode 100644 pkg/integration/tests/sync/fast_forward_rewritten_stack_of_branches.go create mode 100644 pkg/integration/tests/sync/fast_forward_rewritten_stack_of_branches_with_local_commits.go diff --git a/docs-master/Stacked_Branches.md b/docs-master/Stacked_Branches.md index 426fc9203..c9b080e11 100644 --- a/docs-master/Stacked_Branches.md +++ b/docs-master/Stacked_Branches.md @@ -36,3 +36,8 @@ 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/pkg/commands/git_commands/sync.go b/pkg/commands/git_commands/sync.go index c11846c8d..603e5b8eb 100644 --- a/pkg/commands/git_commands/sync.go +++ b/pkg/commands/git_commands/sync.go @@ -6,6 +6,7 @@ import ( "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 { @@ -113,20 +114,25 @@ func (self *SyncCommands) Pull(task gocui.Task, opts PullOptions) error { return self.cmd.New(cmdArgs).AddEnvVars("GIT_SEQUENCE_EDITOR=:").PromptOnCredentialRequest(task).Run() } -// Fetches the given branch of the given remote, updating its remote-tracking -// branch. Local branches are left alone, including the one that tracks it. -func (self *SyncCommands) FetchRemoteBranch( +// 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, 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). - // The explicit destination and the leading + make sure that the - // remote-tracking branch is updated even when the remote branch was - // rewritten, whatever the remote's fetch refspec says - Arg(fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s", - remoteBranchName, remoteName, remoteBranchName)). + Arg(refspecs...). ToArgv() return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 8d51fa09b..d4060e34e 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -143,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, }, @@ -654,21 +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) } // 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 branch.IsAheadForPull() && !branch.IsBehindForPull() { + if lo.SomeBy(branches, func(branch *models.Branch) bool { + return branch.IsAheadForPull() && !branch.IsBehindForPull() + }) { return errors.New(self.c.Tr.FwdCommitsToPush) } - return self.c.Helpers().BranchesHelper.FastForwardBranch(branch) + 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 9e010786c..effbf6a42 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -434,16 +434,33 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool, fe return fetchErr } -// Updates the given branch to its upstream branch, fetching that first. The -// branch is moved forward if it is behind its upstream, and reset to it if it -// has diverged without having any commits of its own. -func (self *BranchesHelper) FastForwardBranch(branch *models.Branch) error { - worktree, checkedOut := self.worktreeForBranch(branch) +// 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 +} - return self.c.WithInlineStatus(branch, types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { +// 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 }) + + return self.withFastForwardingStatus(branches, func(task gocui.Task) error { defer func() { - if checkedOut { - // The files of that worktree have changed as well + 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}}) @@ -452,72 +469,152 @@ func (self *BranchesHelper) FastForwardBranch(branch *models.Branch) error { self.c.LogAction(self.c.Tr.Actions.FastForwardBranch) - err := self.c.Git().Sync.FetchRemoteBranch(task, branch.UpstreamRemote, branch.UpstreamBranch) - if err != nil { + if err := self.fetchUpstreamBranches(task, branches); err != nil { return err } - isFastForward := self.c.Git().Branch.IsAncestor( - branch.FullRefName(), branch.FullUpstreamRefName()) - if !isFastForward { - // 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(branch) - if err != nil { + // 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 } - if hasLocalOnlyCommits { - return errors.New(utils.ResolvePlaceholderString( - self.c.Tr.FwdLocalOnlyCommits, - map[string]string{"branchName": branch.Name}, - )) - } } - if checkedOut { - return self.forwardCheckedOutBranch(branch, worktree, isFastForward) - } - - updateCommand := fmt.Sprintf("update %s %s %s", - branch.FullRefName(), branch.FullUpstreamRefName(), branch.CommitHash) - self.c.LogCommand(updateCommand, false) - return self.c.Git().Branch.UpdateBranchRefs(updateCommand + "\n") + return self.forwardBranches(toForward) }) } -// Updates a branch that is checked out in the given worktree, which needs the -// files there to be updated along with it. -func (self *BranchesHelper) forwardCheckedOutBranch( - branch *models.Branch, worktree *models.Worktree, isFastForward bool, -) error { - worktreeGitDir := "" - worktreePath := "" - // if it is the current worktree path, no need to specify the path - if !worktree.IsCurrent { - worktreeGitDir = worktree.GitDir - worktreePath = worktree.Path +// 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 + } } - if isFastForward { - return self.c.Git().Branch.FastForwardMerge( - branch.FullUpstreamRefName(), worktreeGitDir, worktreePath) + 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 } - // 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 - hasChanges, err := self.c.Git().WorkingTree.HasChangesToTrackedFiles(worktreeGitDir, worktreePath) + // 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 hasChanges { + if hasLocalOnlyCommits { return errors.New(utils.ResolvePlaceholderString( - self.c.Tr.FwdUncommittedChanges, - map[string]string{"branchName": branch.Name}, + self.c.Tr.FwdLocalOnlyCommits, + map[string]string{"branchName": f.branch.Name}, )) } - return self.c.Git().WorkingTree.ResetKeep( - branch.FullUpstreamRefName(), worktreeGitDir, worktreePath) + 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); 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 { 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 5b144e328..d753bbd45 100644 --- a/pkg/integration/tests/sync/shared.go +++ b/pkg/integration/tests/sync/shared.go @@ -80,6 +80,47 @@ func createBranchRewrittenOnTheRemote(shell *Shell) { 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 9ccee7912..a25535ff6 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -483,6 +483,8 @@ var tests = []*components.IntegrationTest{ sync.FastForwardRewrittenBranchWithStaleSubmodule, sync.FastForwardRewrittenBranchWithUncommittedChanges, sync.FastForwardRewrittenBranchWithoutReflogs, + sync.FastForwardRewrittenStackOfBranches, + sync.FastForwardRewrittenStackOfBranchesWithLocalCommits, sync.FetchAndAutoForwardBranchesAllBranches, sync.FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree, sync.FetchAndAutoForwardBranchesNone, From 9183269ee8fb8361499fce1f84064676be33883a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 23 Sep 2026 14:43:51 +0200 Subject: [PATCH 09/11] Write a reflog message when moving branch refs Branches that lazygit forwards to their upstream move by a direct ref write, so no git command turns up in their reflog to explain it. Until now the entry had no message either, leaving `git reflog ` with nothing but a blank line about it. Name lazygit and what it did, so that a branch which moved on its own can be traced. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/branch.go | 6 +++++- pkg/gui/controllers/helpers/branches_helper.go | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index 6d060613a..19dba12fe 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -440,9 +440,13 @@ func (self *BranchCommands) previousUpstreamValues(upstreamRef string) []string return values } -func (self *BranchCommands) UpdateBranchRefs(updateCommands string) error { +// 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/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index effbf6a42..29c27f93e 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -577,7 +577,7 @@ func (self *BranchesHelper) forwardBranches(toForward []*branchToForward) error if updateCommands != "" { self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false) - if err := self.c.Git().Branch.UpdateBranchRefs(updateCommands); err != nil { + if err := self.c.Git().Branch.UpdateBranchRefs(updateCommands, "lazygit: update to upstream branch"); err != nil { return err } } @@ -647,7 +647,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}) From 5990946a23a49593830d0a4691626930e464cd8a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 24 Sep 2026 08:00:31 +0200 Subject: [PATCH 10/11] Add a test for fast-forwarding a branch being rebased in a worktree If a branch is being rebased in a worktree, pressing `f` on it moves the detached HEAD of that worktree instead of the branch. The upstream commits end up in the rebase in progress, and the branch itself stays where it was. The same happens for a branch being bisected. Co-Authored-By: Claude Opus 5.5 (1M context) --- ...orward_branch_being_rebased_in_worktree.go | 69 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 70 insertions(+) create mode 100644 pkg/integration/tests/sync/fast_forward_branch_being_rebased_in_worktree.go 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..2d59589fd --- /dev/null +++ b/pkg/integration/tests/sync/fast_forward_branch_being_rebased_in_worktree.go @@ -0,0 +1,69 @@ +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) + + /* EXPECTED: + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Equals("Cannot fast-forward 'master' because it is being rebased or bisected in worktree linked-worktree")). + Confirm() + ACTUAL: */ + + 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"), + /* EXPECTED: + Contains("two"), + Contains("one"), + ACTUAL: */ + Contains("three"), + Contains("two"), + Contains("one"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a25535ff6..6f9aa5e7b 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -477,6 +477,7 @@ var tests = []*components.IntegrationTest{ sync.FastForwardBehindBranch, sync.FastForwardBehindBranchCheckedOut, sync.FastForwardBehindBranchWithoutReflogs, + sync.FastForwardBranchBeingRebasedInWorktree, sync.FastForwardRewrittenBranch, sync.FastForwardRewrittenBranchCheckedOut, sync.FastForwardRewrittenBranchWithLocalCommits, From ef00e18247bed72e663cab90a99e973aa32a502a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 24 Sep 2026 08:03:52 +0200 Subject: [PATCH 11/11] Refuse to fast-forward a branch being rebased or bisected in a worktree The worktree loader associates a worktree that is mid-rebase or mid-bisect with the branch involved, even though its HEAD is detached. Fast-forwarding such a branch therefore ran the fast-forward in that worktree. This moved the detached HEAD and put the upstream commits into the rebase in progress, while the branch stayed where it was. Remember in the worktree model when its branch comes from a rebase or bisect, and refuse to fast-forward the branch in that case. Updating only the ref is no option for a rebase; the rebase writes the branch when it finishes. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/commands/git_commands/worktree_loader.go | 2 ++ pkg/commands/models/worktree.go | 3 +++ pkg/gui/controllers/helpers/branches_helper.go | 11 +++++++++++ pkg/i18n/english.go | 2 ++ .../fast_forward_branch_being_rebased_in_worktree.go | 7 ------- 5 files changed, 18 insertions(+), 7 deletions(-) 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/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/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 29c27f93e..e3586fa52 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -457,6 +457,17 @@ func (self *BranchesHelper) FastForwardBranches(branches []*models.Branch) error }) 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 { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 0a324a14a..3cfe7bd78 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -376,6 +376,7 @@ type TranslationSet struct { FwdCommitsToPush string FwdLocalOnlyCommits string FwdUncommittedChanges string + FwdBranchRebasingOrBisecting string PullRequestNoUpstream string PullRequestChecksPassing string PullRequestChecksPending string @@ -1551,6 +1552,7 @@ func EnglishTranslationSet() *TranslationSet { 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_branch_being_rebased_in_worktree.go b/pkg/integration/tests/sync/fast_forward_branch_being_rebased_in_worktree.go index 2d59589fd..dabcc5dac 100644 --- 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 @@ -39,12 +39,10 @@ var FastForwardBranchBeingRebasedInWorktree = NewIntegrationTest(NewIntegrationT NavigateToLine(Contains("master")). Press(keys.Branches.FastForward) - /* EXPECTED: t.ExpectPopup().Alert(). Title(Equals("Error")). Content(Equals("Cannot fast-forward 'master' because it is being rebased or bisected in worktree linked-worktree")). Confirm() - ACTUAL: */ t.Views().Worktrees(). Focus(). @@ -57,11 +55,6 @@ var FastForwardBranchBeingRebasedInWorktree = NewIntegrationTest(NewIntegrationT Lines( Contains("─── Pending rebase todos"), Contains("─── Commits"), - /* EXPECTED: - Contains("two"), - Contains("one"), - ACTUAL: */ - Contains("three"), Contains("two"), Contains("one"), )