diff --git a/pkg/gui/controllers/helpers/suggestions_helper.go b/pkg/gui/controllers/helpers/suggestions_helper.go index e88fe3822..d26f96f1c 100644 --- a/pkg/gui/controllers/helpers/suggestions_helper.go +++ b/pkg/gui/controllers/helpers/suggestions_helper.go @@ -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 diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 3eef892cf..2bc877444 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -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) }) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index bb8d8637e..822e47c10 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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}}'", diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 443b29fad..5aa8a21ee 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -517,6 +517,8 @@ var tests = []*components.IntegrationTest{ worktree.ForceRemoveWorktree, worktree.ForceRemoveWorktreeWithSubmodules, worktree.LocationCandidates, + worktree.NewWorktreePicker, + worktree.NewWorktreePickerRemote, worktree.RemoveWorktreeFromBranch, worktree.ResetWindowTabs, worktree.SymlinkIntoRepoSubdir, diff --git a/pkg/integration/tests/worktree/crud.go b/pkg/integration/tests/worktree/crud.go index cd539d10b..6cda94141 100644 --- a/pkg/integration/tests/worktree/crud.go +++ b/pkg/integration/tests/worktree/crud.go @@ -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(), diff --git a/pkg/integration/tests/worktree/new_worktree_picker.go b/pkg/integration/tests/worktree/new_worktree_picker.go new file mode 100644 index 000000000..58915e8b5 --- /dev/null +++ b/pkg/integration/tests/worktree/new_worktree_picker.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/worktree/new_worktree_picker_remote.go b/pkg/integration/tests/worktree/new_worktree_picker_remote.go new file mode 100644 index 000000000..55b156d7f --- /dev/null +++ b/pkg/integration/tests/worktree/new_worktree_picker_remote.go @@ -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(), + ) + }, +}) diff --git a/pkg/integration/tests/worktree/worktree_in_repo.go b/pkg/integration/tests/worktree/worktree_in_repo.go index 5f0ee66ec..f6b5b44a3 100644 --- a/pkg/integration/tests/worktree/worktree_in_repo.go +++ b/pkg/integration/tests/worktree/worktree_in_repo.go @@ -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(),