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) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-09-27 11:00:36 +02:00
co-authored by Claude Opus 5.5
parent 5990946a23
commit ef00e18247
5 changed files with 18 additions and 7 deletions
@@ -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
}
}
+3
View File
@@ -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
@@ -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 {
+2
View File
@@ -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",
@@ -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"),
)