Rework the worktrees-panel 'n' into a branch picker

The old 'n' flow opened a "normal vs detached" menu (the same meaningless
gate the 'w' flow used to have), then asked for a base ref, a path typed from
scratch, and a branch name in three separate prompts.

Replace it with a single picker prompt titled "New worktree for branch",
suggesting local branches not already checked out anywhere, plus remote
branches that don't yet have a local branch of the same name. The entered
value is classified on confirm: an existing local branch checks out into a
new worktree, a remote branch creates a new local tracking branch, and
anything else creates a new branch off the current ref. All three then feed
the same location menu the 'w' flow uses, so paths are chosen from candidates
rather than typed blind. Picking a remote or new branch needs no separate
name prompt — the picker value already is the name. Checked-out branches are
filtered from the suggestions, and a verbatim type-in of one is rejected with
an error.

createWorktree now takes the context to switch focus to once the worktree is
created, so 'n' lands back in the worktrees panel while 'w' still lands in
the branches panel.

This deletes the old NewWorktree / NewWorktreeCheckout core and the now-
orphaned i18n (CreateWorktreeFrom, CreateWorktreeFromDetached, NewWorktreeBase,
NewBranchNameLeaveBlank), completing the migration started for 'w'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-29 15:26:38 +02:00
parent 23d01ac1bd
commit 768d9f1a3f
8 changed files with 217 additions and 127 deletions

View file

@ -5,6 +5,7 @@ import (
"strings"
"github.com/jesseduffield/generics/set"
"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/presentation"
@ -84,6 +85,28 @@ func (self *SuggestionsHelper) GetBranchNameSuggestionsFunc() func(string) []*ty
}
}
// GetWorktreeBranchNameSuggestionsFunc suggests branches you can base a new
// worktree on: local branches that aren't checked out in any worktree (you can't
// make a second worktree for them), plus remote branches that don't yet have a
// local branch of the same name. Picking a remote branch creates a new local
// tracking branch, which would fail if that local branch already existed (whether
// or not it's checked out), so we leave those out and you reach the branch via its
// local entry instead.
func (self *SuggestionsHelper) GetWorktreeBranchNameSuggestionsFunc() func(string) []*types.Suggestion {
localBranchNames := lo.FilterMap(self.c.Model().Branches, func(branch *models.Branch, _ int) (string, bool) {
_, checkedOut := git_commands.WorktreeForBranch(branch, self.c.Model().Worktrees)
return branch.Name, !checkedOut
})
existingLocalBranches := set.NewFromSlice(self.getBranchNames())
remoteBranchNames := lo.Filter(self.getRemoteBranchNames("/"), func(remoteBranchName string, _ int) bool {
_, branchName, _ := strings.Cut(remoteBranchName, "/")
return !existingLocalBranches.Includes(branchName)
})
return FilterFunc(append(localBranchNames, remoteBranchNames...), self.c.UserConfig().Gui.UseFuzzySearch())
}
// here we asynchronously fetch the latest set of paths in the repo and store in
// self.c.Model().FilesTrie. On the main thread we'll be doing a fuzzy search via
// self.c.Model().FilesTrie. So if we've looked for a file previously, we'll start with

View file

@ -57,105 +57,58 @@ func (self *WorktreeHelper) GetLinkedWorktreeName() string {
}
func (self *WorktreeHelper) NewWorktree() error {
branch := self.refsHelper.GetCheckedOutRef()
currentBranchName := branch.RefName()
f := func(detached bool) {
self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.NewWorktreeBase,
InitialContent: currentBranchName,
FindSuggestionsFunc: self.suggestionsHelper.GetRefsSuggestionsFunc(),
HandleConfirm: func(base string) error {
// we assume that the base can be checked out
canCheckoutBase := true
return self.NewWorktreeCheckout(base, canCheckoutBase, detached, context.WORKTREES_CONTEXT_KEY)
},
})
}
placeholders := map[string]string{"ref": "ref"}
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.WorktreeTitle,
Items: []*types.MenuItem{
{
LabelColumns: []string{utils.ResolvePlaceholderString(self.c.Tr.CreateWorktreeFrom, placeholders)},
OnPress: func() error {
f(false)
return nil
},
},
{
LabelColumns: []string{utils.ResolvePlaceholderString(self.c.Tr.CreateWorktreeFromDetached, placeholders)},
OnPress: func() error {
f(true)
return nil
},
},
},
})
}
func (self *WorktreeHelper) NewWorktreeCheckout(base string, canCheckoutBase bool, detached bool, contextKey types.ContextKey) error {
opts := git_commands.NewWorktreeOpts{
Base: base,
Detach: detached,
}
f := func() error {
return self.c.WithWaitingStatus(self.c.Tr.AddingWorktree, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.AddWorktree)
if err := self.c.Git().Worktree.New(opts); err != nil {
return err
}
return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey)
})
}
self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.NewWorktreePath,
HandleConfirm: func(path string) error {
opts.Path = path
if detached {
return f()
}
if canCheckoutBase {
title := utils.ResolvePlaceholderString(self.c.Tr.NewBranchNameLeaveBlank, map[string]string{"default": base})
// prompt for the new branch name where a blank means we just check out the branch
self.c.Prompt(types.PromptOpts{
Title: title,
HandleConfirm: func(branchName string) error {
opts.Branch = branchName
return f()
},
AllowEmptyInput: true,
})
return nil
}
// prompt for the new branch name
self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.NewBranchName,
HandleConfirm: func(branchName string) error {
opts.Branch = branchName
return f()
},
AllowEmptyInput: false,
})
return nil
Title: self.c.Tr.NewWorktreeForBranchTitle,
FindSuggestionsFunc: self.suggestionsHelper.GetWorktreeBranchNameSuggestionsFunc(),
HandleConfirm: func(value string) error {
return self.newWorktreeForPickerValue(value)
},
})
return nil
}
// newWorktreeForPickerValue classifies the value the user picked or typed in the
// worktrees-panel picker and routes to the matching creation flow:
// - an existing local branch -> a worktree that checks it out;
// - a remote branch -> a new local tracking branch + worktree;
// - anything else -> a new branch off the current ref + worktree.
//
// All three then feed the shared location menu. The picker filters out branches
// already checked out somewhere, but a verbatim type-in is still guarded here.
func (self *WorktreeHelper) newWorktreeForPickerValue(value string) error {
if branch, ok := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool {
return branch.Name == value
}); ok {
if worktree, ok := git_commands.WorktreeForBranch(branch, self.c.Model().Worktrees); ok {
return errors.New(utils.ResolvePlaceholderString(self.c.Tr.BranchCheckedOutByWorktree,
map[string]string{"branchName": branch.Name, "worktreeName": worktree.Name}))
}
prompt := utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptCheckout,
map[string]string{"branchName": branch.Name})
return self.promptForWorktreeLocation(branch.Name, prompt, func(path string) error {
return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: branch.RefName()}, context.WORKTREES_CONTEXT_KEY)
})
}
if _, branchName, ok := self.refsHelper.ParseRemoteBranchName(value); ok {
prompt := utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptTrackingBranch,
map[string]string{"name": branchName, "ref": value})
return self.promptForWorktreeLocation(branchName, prompt, func(path string) error {
return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: value, Branch: branchName}, context.WORKTREES_CONTEXT_KEY)
})
}
name := SanitizedBranchName(value)
base := self.refsHelper.GetCheckedOutRef().RefName()
prompt := utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptNewBranch,
map[string]string{"name": name, "base": base})
return self.promptForWorktreeLocation(name, prompt, func(path string) error {
return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: base, Branch: name}, context.WORKTREES_CONTEXT_KEY)
})
}
func (self *WorktreeHelper) Switch(worktree *models.Worktree, contextKey types.ContextKey) error {
if worktree.IsCurrent {
return errors.New(self.c.Tr.AlreadyInWorktree)
@ -339,7 +292,7 @@ func (self *WorktreeHelper) newLocalBranchAndWorktreeItem(remoteBranch *models.R
func (self *WorktreeHelper) startNewBranchWorktree(nameInitialContent string, base string, locationPrompt func(name string) string) error {
return self.promptForName(self.c.Tr.NewBranchAndWorktreeName, nameInitialContent, func(name string) error {
return self.promptForWorktreeLocation(name, locationPrompt(name), func(path string) error {
return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: base, Branch: name})
return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: base, Branch: name}, context.LOCAL_BRANCHES_CONTEXT_KEY)
})
})
}
@ -354,7 +307,7 @@ func (self *WorktreeHelper) worktreeForBranchItem(branch *models.Branch) *types.
prompt := utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptCheckout,
map[string]string{"branchName": branch.Name})
return self.promptForWorktreeLocation(branch.Name, prompt, func(path string) error {
return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: branch.RefName()})
return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: branch.RefName()}, context.LOCAL_BRANCHES_CONTEXT_KEY)
})
},
DisabledReason: self.branchCheckedOutDisabledReason(branch),
@ -368,7 +321,7 @@ func (self *WorktreeHelper) worktreeForBranchItem(branch *models.Branch) *types.
func (self *WorktreeHelper) detachedWorktreeItem(ref string, base string, defaultDirName string) *types.MenuItem {
prompt := utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptDetached, map[string]string{"ref": ref})
create := func(path string) error {
return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: base, Detach: true})
return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: base, Detach: true}, context.LOCAL_BRANCHES_CONTEXT_KEY)
}
return &types.MenuItem{
@ -458,13 +411,13 @@ func (self *WorktreeHelper) promptForWorktreeLocation(dirName string, prompt str
})
}
func (self *WorktreeHelper) createWorktree(opts git_commands.NewWorktreeOpts) error {
func (self *WorktreeHelper) createWorktree(opts git_commands.NewWorktreeOpts, contextKey types.ContextKey) error {
return self.c.WithWaitingStatus(self.c.Tr.AddingWorktree, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.AddWorktree)
if err := self.c.Git().Worktree.New(opts); err != nil {
return err
}
return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, context.LOCAL_BRANCHES_CONTEXT_KEY)
return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey)
})
}

View file

@ -890,13 +890,10 @@ type TranslationSet struct {
MainWorktree string
NewWorktree string
NewWorktreePath string
NewWorktreeBase string
RemoveWorktreeTooltip string
NewBranchName string
NewBranchNameLeaveBlank string
CreateWorktreeFrom string
CreateWorktreeFromDetached string
NewWorktreeName string
NewWorktreeForBranchTitle string
NewBranchAndWorktreeName string
NewBranchAndWorktreeFromRef string
NewLocalBranchAndWorktreeFromRef string
@ -2035,13 +2032,10 @@ func EnglishTranslationSet() *TranslationSet {
MainWorktree: "(main worktree)",
NewWorktree: "New worktree",
NewWorktreePath: "New worktree path",
NewWorktreeBase: "New worktree base ref",
RemoveWorktreeTooltip: "Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory.",
NewBranchName: "New branch name",
NewBranchNameLeaveBlank: "New branch name (leave blank to checkout {{.default}})",
CreateWorktreeFrom: "Create worktree from {{.ref}}",
CreateWorktreeFromDetached: "Create worktree from {{.ref}} (detached)",
NewWorktreeName: "New worktree name",
NewWorktreeForBranchTitle: "New worktree for branch",
NewBranchAndWorktreeName: "New branch and worktree name",
NewBranchAndWorktreeFromRef: "New branch and worktree from '{{.ref}}'",
NewLocalBranchAndWorktreeFromRef: "New local branch and worktree from '{{.ref}}'",

View file

@ -517,6 +517,8 @@ var tests = []*components.IntegrationTest{
worktree.ForceRemoveWorktree,
worktree.ForceRemoveWorktreeWithSubmodules,
worktree.LocationCandidates,
worktree.NewWorktreePicker,
worktree.NewWorktreePickerRemote,
worktree.RemoveWorktreeFromBranch,
worktree.ResetWindowTabs,
worktree.SymlinkIntoRepoSubdir,

View file

@ -33,25 +33,23 @@ var Crud = NewIntegrationTest(NewIntegrationTestArgs{
).
Press(keys.Universal.New).
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Worktree")).
Select(Contains(`Create worktree from ref`).DoesNotContain(("detached"))).
// a name that isn't an existing branch creates a new branch off
// the current one
t.ExpectPopup().Prompt().
Title(Equals("New worktree for branch")).
Type("newbranch").
Confirm()
t.ExpectPopup().Prompt().
Title(Equals("New worktree base ref")).
InitialText(Equals("mybranch")).
t.ExpectPopup().Menu().
Title(Equals("Worktree location")).
Select(Contains("Other…")).
Confirm()
t.ExpectPopup().Prompt().
Title(Equals("New worktree path")).
Clear().
Type("../linked-worktree").
Confirm()
t.ExpectPopup().Prompt().
Title(Equals("New branch name (leave blank to checkout mybranch)")).
Type("newbranch").
Confirm()
}).
Lines(
Contains("linked-worktree").IsSelected(),

View file

@ -0,0 +1,64 @@
package worktree
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var NewWorktreePicker = NewIntegrationTest(NewIntegrationTestArgs{
Description: "From the worktrees panel, the picker suggests only branches not already checked out, guards verbatim type-ins, and checks out an existing branch",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.NewBranch("mybranch")
shell.CreateFileAndAdd("README.md", "hello world")
shell.Commit("initial commit")
shell.NewBranchFrom("feature", "mybranch")
shell.Checkout("mybranch")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Worktrees().
Focus().
Lines(
Contains("(main worktree)"),
).
Press(keys.Universal.New).
Tap(func() {
// mybranch is checked out by the current worktree, so it's not
// suggested; feature is
t.ExpectPopup().Prompt().
Title(Equals("New worktree for branch")).
SuggestionLines(Contains("feature")).
// typing a checked-out branch verbatim is still rejected
Type("mybranch").
Confirm()
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Contains("Branch mybranch is checked out by worktree repo")).
Confirm()
}).
Press(keys.Universal.New).
Tap(func() {
// picking an existing branch checks it out (no new branch)
t.ExpectPopup().Prompt().
Title(Equals("New worktree for branch")).
Type("feature").
Confirm()
t.ExpectPopup().Menu().
Title(Equals("Worktree location")).
Confirm()
}).
// we stay in the worktrees panel, now switched into the new worktree
IsFocused().
Lines(
Contains("feature").IsSelected(),
Contains("(main worktree)"),
)
t.Views().Status().
Content(Contains("repo(feature) → feature"))
},
})

View file

@ -0,0 +1,60 @@
package worktree
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var NewWorktreePickerRemote = NewIntegrationTest(NewIntegrationTestArgs{
Description: "From the worktrees panel, picking a remote branch creates a new local tracking branch and worktree; remote branches whose local branch already exists are filtered out",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("README.md", "hello world")
shell.Commit("initial commit")
shell.NewBranch("feature")
shell.NewBranch("existing")
shell.CloneIntoRemote("origin")
shell.Checkout("master")
// "feature" now exists only on the remote; "existing" stays as a local
// branch (not checked out) that also has a remote counterpart
shell.RunCommand([]string{"git", "branch", "-D", "feature"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Worktrees().
Focus().
Press(keys.Universal.New).
Tap(func() {
// master is checked out, so neither it nor origin/master is
// offered; "existing" already has a local branch, so
// origin/existing is left out too (you'd reach it via the local
// entry); origin/feature has no local branch, so it's offered
t.ExpectPopup().Prompt().
Title(Equals("New worktree for branch")).
SuggestionLines(
Contains("existing"),
Contains("origin/feature"),
).
Type("origin/feature").
Confirm()
t.ExpectPopup().Menu().
Title(Equals("Worktree location")).
Confirm()
}).
IsFocused().
Lines(
Contains("feature").IsSelected(),
Contains("(main worktree)"),
)
// the new worktree is on a local branch that tracks the remote one (the
// ✓ confirms tracking is set up)
t.Views().Branches().
Focus().
ContainsLines(
Contains("feature").Contains("✓").IsSelected(),
)
},
})

View file

@ -28,25 +28,21 @@ var WorktreeInRepo = NewIntegrationTest(NewIntegrationTestArgs{
).
Press(keys.Universal.New).
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Worktree")).
Select(Contains(`Create worktree from ref`).DoesNotContain(("detached"))).
t.ExpectPopup().Prompt().
Title(Equals("New worktree for branch")).
Type("newbranch").
Confirm()
t.ExpectPopup().Prompt().
Title(Equals("New worktree base ref")).
InitialText(Equals("mybranch")).
t.ExpectPopup().Menu().
Title(Equals("Worktree location")).
Select(Contains("Other…")).
Confirm()
t.ExpectPopup().Prompt().
Title(Equals("New worktree path")).
Clear().
Type("linked-worktree").
Confirm()
t.ExpectPopup().Prompt().
Title(Equals("New branch name (leave blank to checkout mybranch)")).
Type("newbranch").
Confirm()
}).
Lines(
Contains("linked-worktree").IsSelected(),