diff --git a/AGENTS.md b/AGENTS.md index 58e0a38f3..46ad3e506 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -279,6 +279,29 @@ languages), and the map form extends cleanly when a string later needs more than one placeholder. This holds for every user-facing string, including short ones like disabled-action reasons and toasts. +## Only edit the English translations + +`pkg/i18n/english.go` is the one translation file you edit; add, change, and +remove strings there. The other languages under `pkg/i18n/translations/` are +maintained by Crowdin and synced automatically — never edit them by hand, not +even to add a key you just introduced or to delete one you just removed. A +removed English string simply leaves an orphan key in those files, which +Crowdin cleans up on its own; an unknown key in a translation file is ignored +at load time, so it does no harm in the meantime. + +## Try to keep new english.go strings within the existing column alignment + +`gofumpt` aligns the `TranslationSet` struct fields and the `EnglishTranslationSet` +literal into columns, so a new field whose name is longer than the widest one in +its alignment block re-indents every line in that block. When there are several +feature branches in flight that all add strings, that reformatting churn turns +english.go into a rebase-conflict magnet. So when it's cheap to do so, make an +effort to keep a new field name within the current widest name in the block +(measure it; it's around 40 characters today), shortening the Go field name to +fit. This is a soft preference, not a rule: the usual "best name wins" still +applies, so don't mangle a name past the point of readability just to save a +column. Applies only to `pkg/i18n/english.go`. + ## Code comments are for future readers, not development history Comments in source code explain *why this code is shaped the way it is*. They diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index ccc9d33ac..4283bd29a 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -8,7 +8,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/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" @@ -32,20 +31,17 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error return errors.New(self.c.Tr.SomeBranchesCheckedOutByWorktreeError) } } else if self.checkedOutByOtherWorktree(branches[0]) { - return self.promptWorktreeBranchDelete(branches[0]) + return self.promptWorktreeBranchDelete( + branches[0], + self.c.Tr.RemoveWorktreeAndDeleteBranch, + self.c.Tr.DetachWorktreeAndDeleteBranch, + self.deleteLocalBranchesContinuation(branches), + ) } - allBranchesMerged, err := self.allBranchesMerged(branches) - if err != nil { - return err - } - - doDelete := func() error { + return self.confirmForceIfUnmerged(branches, func() error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(_ gocui.Task) error { - self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch) - self.logBranchHashes(branches) - branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.Name }) - if err := self.c.Git().Branch.LocalDelete(branchNames, true); err != nil { + if err := self.doDeleteLocalBranches(branches); err != nil { return err } @@ -53,34 +49,7 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) return nil }) - } - - if allBranchesMerged { - return doDelete() - } - - title := self.c.Tr.ForceDeleteBranchTitle - var message string - if len(branches) == 1 { - message = utils.ResolvePlaceholderString( - self.c.Tr.ForceDeleteBranchMessage, - map[string]string{ - "selectedBranchName": branches[0].Name, - }, - ) - } else { - message = self.c.Tr.ForceDeleteBranchesMessage - } - - self.c.Confirm(types.ConfirmOpts{ - Title: title, - Prompt: message, - HandleConfirm: func() error { - return doDelete() - }, }) - - return nil } func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteBranch, resetRemoteBranchesSelection bool) error { @@ -128,8 +97,17 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteB } func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branch) error { - if lo.SomeBy(branches, func(branch *models.Branch) bool { return self.checkedOutByOtherWorktree(branch) }) { - return errors.New(self.c.Tr.SomeBranchesCheckedOutByWorktreeError) + if len(branches) > 1 { + if lo.SomeBy(branches, func(branch *models.Branch) bool { return self.checkedOutByOtherWorktree(branch) }) { + return errors.New(self.c.Tr.SomeBranchesCheckedOutByWorktreeError) + } + } else if self.checkedOutByOtherWorktree(branches[0]) { + return self.promptWorktreeBranchDelete( + branches[0], + self.c.Tr.RemoveWorktreeAndDeleteBothBranches, + self.c.Tr.DetachWorktreeAndDeleteBothBranches, + self.deleteLocalAndRemoteBranchesContinuation(branches), + ) } allBranchesMerged, err := self.allBranchesMerged(branches) @@ -169,19 +147,7 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branc Prompt: prompt, HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(task gocui.Task) error { - // Delete the remote branches first so that we keep the local ones - // in case of failure - remoteBranches := lo.Map(branches, func(branch *models.Branch, _ int) *models.RemoteBranch { - return &models.RemoteBranch{Name: branch.UpstreamBranch, RemoteName: branch.UpstreamRemote} - }) - if err := self.deleteRemoteBranches(remoteBranches, task); err != nil { - return err - } - - self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch) - self.logBranchHashes(branches) - branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.Name }) - if err := self.c.Git().Branch.LocalDelete(branchNames, true); err != nil { + if err := self.doDeleteLocalAndRemoteBranches(task, branches); err != nil { return err } @@ -207,8 +173,18 @@ func (self *BranchesHelper) worktreeForBranch(branch *models.Branch) (*models.Wo return git_commands.WorktreeForBranch(branch, self.c.Model().Worktrees) } -func (self *BranchesHelper) promptWorktreeBranchDelete(selectedBranch *models.Branch) error { - worktree, ok := self.worktreeForBranch(selectedBranch) +// promptWorktreeBranchDelete handles deleting a branch that's checked out by +// another worktree: the worktree has to be removed or detached first to free the +// branch, so we offer both as menu items. Either way the branch is deleted +// afterwards (that's what the user asked for), via deleteBranches, which knows +// whether to delete just the local branch or the remote one too. +func (self *BranchesHelper) promptWorktreeBranchDelete( + branch *models.Branch, + removeLabel string, + detachLabel string, + deleteBranches func(gocui.Task) error, +) error { + worktree, ok := self.worktreeForBranch(branch) if !ok { self.c.Log.Error("promptWorktreeBranchDelete out of sync with list of worktrees") return nil @@ -216,34 +192,152 @@ func (self *BranchesHelper) promptWorktreeBranchDelete(selectedBranch *models.Br title := utils.ResolvePlaceholderString(self.c.Tr.BranchCheckedOutByWorktree, map[string]string{ "worktreeName": worktree.Name, - "branchName": selectedBranch.Name, + "branchName": branch.Name, }) return self.c.Menu(types.CreateMenuOptions{ Title: title, Items: []*types.MenuItem{ { - Label: self.c.Tr.SwitchToWorktree, + Label: removeLabel, + Keys: menuKey('r'), OnPress: func() error { - return self.worktreeHelper.Switch(worktree, context.LOCAL_BRANCHES_CONTEXT_KEY) + return self.confirmForceIfUnmerged([]*models.Branch{branch}, func() error { + return self.worktreeHelper.Remove(worktree, deleteBranches) + }) }, }, { - Label: self.c.Tr.DetachWorktree, + Label: detachLabel, + Keys: menuKey('d'), Tooltip: self.c.Tr.DetachWorktreeTooltip, OnPress: func() error { - return self.worktreeHelper.Detach(worktree) - }, - }, - { - Label: self.c.Tr.RemoveWorktree, - OnPress: func() error { - return self.worktreeHelper.Remove(worktree, false) + return self.confirmForceIfUnmerged([]*models.Branch{branch}, func() error { + return self.worktreeHelper.Detach(worktree, deleteBranches) + }) }, }, }, }) } +// RemoveWorktreeAndDeleteBranch removes the worktree and deletes the local branch +// it has checked out, force-warning first if the branch isn't fully merged. It's +// the worktrees-panel counterpart to deleting a worktree-checked-out branch from +// the branches panel. +func (self *BranchesHelper) RemoveWorktreeAndDeleteBranch( + worktree *models.Worktree, branch *models.Branch, +) error { + branches := []*models.Branch{branch} + return self.removeWorktreeAndDelete(worktree, branches, + self.deleteLocalBranchesContinuation(branches)) +} + +// RemoveWorktreeAndDeleteBothBranches is like RemoveWorktreeAndDeleteBranch but +// also deletes the branch's upstream. +func (self *BranchesHelper) RemoveWorktreeAndDeleteBothBranches( + worktree *models.Worktree, branch *models.Branch, +) error { + branches := []*models.Branch{branch} + return self.removeWorktreeAndDelete(worktree, branches, + self.deleteLocalAndRemoteBranchesContinuation(branches)) +} + +func (self *BranchesHelper) removeWorktreeAndDelete( + worktree *models.Worktree, branches []*models.Branch, deleteBranches func(gocui.Task) error, +) error { + return self.confirmForceIfUnmerged(branches, func() error { + return self.worktreeHelper.Remove(worktree, deleteBranches) + }) +} + +// confirmForceIfUnmerged runs onConfirm directly if all the branches are fully +// merged, and otherwise shows the force-delete warning first and runs onConfirm +// when the user confirms it. +func (self *BranchesHelper) confirmForceIfUnmerged(branches []*models.Branch, onConfirm func() error) error { + allBranchesMerged, err := self.allBranchesMerged(branches) + if err != nil { + return err + } + if allBranchesMerged { + return onConfirm() + } + + var message string + if len(branches) == 1 { + message = utils.ResolvePlaceholderString( + self.c.Tr.ForceDeleteBranchMessage, + map[string]string{ + "selectedBranchName": branches[0].Name, + }, + ) + } else { + message = self.c.Tr.ForceDeleteBranchesMessage + } + + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.ForceDeleteBranchTitle, + Prompt: message, + HandleConfirm: onConfirm, + }) + + return nil +} + +func (self *BranchesHelper) doDeleteLocalBranches(branches []*models.Branch) error { + self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch) + self.logBranchHashes(branches) + branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.Name }) + return self.c.Git().Branch.LocalDelete(branchNames, true) +} + +func (self *BranchesHelper) doDeleteLocalAndRemoteBranches(task gocui.Task, branches []*models.Branch) error { + // Delete the remote branches first so that we keep the local ones + // in case of failure + remoteBranches := lo.Map(branches, func(branch *models.Branch, _ int) *models.RemoteBranch { + return &models.RemoteBranch{Name: branch.UpstreamBranch, RemoteName: branch.UpstreamRemote} + }) + if err := self.deleteRemoteBranches(remoteBranches, task); err != nil { + return err + } + + return self.doDeleteLocalBranches(branches) +} + +// deleteLocalBranchesContinuation returns a worktree-removal continuation that +// deletes the local branches and refreshes once the worktree is out of the way. +func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.Branch) func(gocui.Task) error { + return func(gocui.Task) error { + if err := self.doDeleteLocalBranches(branches); err != nil { + return err + } + + self.c.Contexts().Branches.CollapseRangeSelectionToTop() + self.c.Refresh(types.RefreshOptions{ + Mode: types.ASYNC, + Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}, + }) + return nil + } +} + +// deleteLocalAndRemoteBranchesContinuation returns a worktree-removal +// continuation that deletes the local and remote branches and refreshes once the +// worktree is out of the way. +func (self *BranchesHelper) deleteLocalAndRemoteBranchesContinuation(branches []*models.Branch) func(gocui.Task) error { + return func(task gocui.Task) error { + if err := self.doDeleteLocalAndRemoteBranches(task, branches); err != nil { + return err + } + + self.c.Contexts().Branches.CollapseRangeSelectionToTop() + self.c.Refresh(types.RefreshOptions{ + Mode: types.ASYNC, + Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.REMOTES, types.FILES}, + }) + return nil + } +} + func (self *BranchesHelper) allBranchesMerged(branches []*models.Branch) (bool, error) { allBranchesMerged := true for _, branch := range branches { diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 7a32d898c..7cec9f873 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -119,56 +119,68 @@ func (self *WorktreeHelper) Switch(worktree *models.Worktree, contextKey types.C return self.reposHelper.DispatchSwitchTo(worktree.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey) } -func (self *WorktreeHelper) Remove(worktree *models.Worktree, force bool) error { - title := self.c.Tr.RemoveWorktreeTitle - var templateStr string - if force { - templateStr = self.c.Tr.ForceRemoveWorktreePrompt - } else { - templateStr = self.c.Tr.RemoveWorktreePrompt - } - message := utils.ResolvePlaceholderString( - templateStr, - map[string]string{ - "worktreeName": worktree.Name, - }, - ) - - self.c.Confirm(types.ConfirmOpts{ - Title: title, - Prompt: message, - HandleConfirm: func() error { - return self.c.WithWaitingStatus(self.c.Tr.RemovingWorktree, func(gocui.Task) error { - self.c.LogAction(self.c.Tr.RemoveWorktree) - if err := self.c.Git().Worktree.Delete(worktree.Path, force); err != nil { - errMessage := err.Error() - if !strings.Contains(errMessage, "--force") && - !strings.Contains(errMessage, "fatal: working trees containing submodules cannot be moved or removed") { - return err - } - - if !force { - return self.Remove(worktree, true) - } - return err - } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) - return nil - }) - }, - }) - - return nil +// Remove deletes the worktree without confirming first; callers are expected to +// have confirmed (or shown a menu) already. If git refuses because the worktree +// is dirty or contains submodules, we ask for confirmation and retry with +// --force. When then is non-nil it runs in place of the default refresh after a +// successful removal, letting callers chain further work such as deleting the +// worktree's branch. +func (self *WorktreeHelper) Remove(worktree *models.Worktree, then func(gocui.Task) error) error { + return self.remove(worktree, false, then) } -func (self *WorktreeHelper) Detach(worktree *models.Worktree) error { - return self.c.WithWaitingStatus(self.c.Tr.DetachingWorktree, func(gocui.Task) error { +func (self *WorktreeHelper) remove(worktree *models.Worktree, force bool, then func(gocui.Task) error) error { + return self.c.WithWaitingStatus(self.c.Tr.RemovingWorktree, func(task gocui.Task) error { + self.c.LogAction(self.c.Tr.RemoveWorktree) + if err := self.c.Git().Worktree.Delete(worktree.Path, force); err != nil { + errMessage := err.Error() + if !strings.Contains(errMessage, "--force") && + !strings.Contains(errMessage, "fatal: working trees containing submodules cannot be moved or removed") { + return err + } + + if force { + return err + } + + message := utils.ResolvePlaceholderString( + self.c.Tr.ForceRemoveWorktreePrompt, + map[string]string{ + "worktreeName": worktree.Name, + }, + ) + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.RemoveWorktreeTitle, + Prompt: message, + HandleConfirm: func() error { + return self.remove(worktree, true, then) + }, + }) + return nil + } + + if then != nil { + return then(task) + } + + self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) + return nil + }) +} + +func (self *WorktreeHelper) Detach(worktree *models.Worktree, then func(gocui.Task) error) error { + return self.c.WithWaitingStatus(self.c.Tr.DetachingWorktree, func(task gocui.Task) error { self.c.LogAction(self.c.Tr.RemovingWorktree) err := self.c.Git().Worktree.Detach(worktree.Path) if err != nil { return err } + + if then != nil { + return then(task) + } + self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) return nil }) diff --git a/pkg/gui/controllers/worktrees_controller.go b/pkg/gui/controllers/worktrees_controller.go index 5128ad716..02d20b3f2 100644 --- a/pkg/gui/controllers/worktrees_controller.go +++ b/pkg/gui/controllers/worktrees_controller.go @@ -11,6 +11,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" ) type WorktreesController struct { @@ -130,7 +131,53 @@ func (self *WorktreesController) remove(worktree *models.Worktree) error { return errors.New(self.c.Tr.CantDeleteCurrentWorktree) } - return self.c.Helpers().Worktree.Remove(worktree, false) + removeWorktreeItem := &types.MenuItem{ + Label: self.c.Tr.RemoveWorktree, + Keys: menuKey('w'), + OnPress: func() error { + return self.c.Helpers().Worktree.Remove(worktree, nil) + }, + } + + branch, branchFound := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool { + return branch.Name == worktree.Branch + }) + // A worktree with a detached HEAD has no branch to delete + detachedReason := &types.DisabledReason{Text: self.c.Tr.WorktreeNotCheckedOutOnBranch} + + removeWorktreeAndBranchItem := &types.MenuItem{ + Label: self.c.Tr.RemoveWorktreeAndDeleteBranch, + Keys: menuKey('b'), + OnPress: func() error { + return self.c.Helpers().BranchesHelper.RemoveWorktreeAndDeleteBranch(worktree, branch) + }, + } + if !branchFound { + removeWorktreeAndBranchItem.DisabledReason = detachedReason + } + + removeWorktreeAndBothBranchesItem := &types.MenuItem{ + Label: self.c.Tr.RemoveWorktreeAndDeleteBothBranches, + Keys: menuKey('r'), + OnPress: func() error { + return self.c.Helpers().BranchesHelper.RemoveWorktreeAndDeleteBothBranches(worktree, branch) + }, + } + if !branchFound { + removeWorktreeAndBothBranchesItem.DisabledReason = detachedReason + } else if !branch.IsTrackingRemote() || branch.UpstreamGone { + removeWorktreeAndBothBranchesItem.DisabledReason = &types.DisabledReason{ + Text: self.c.Tr.UpstreamNotSetError, + } + } + + return self.c.Menu(types.CreateMenuOptions{ + Title: utils.ResolvePlaceholderString( + self.c.Tr.RemoveWorktreeMenuTitle, + map[string]string{"worktreeName": worktree.Name}, + ), + Items: []*types.MenuItem{removeWorktreeItem, removeWorktreeAndBranchItem, removeWorktreeAndBothBranchesItem}, + }) } func (self *WorktreesController) GetOnDoubleClick() func() error { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 822e47c10..be3886fbe 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -874,11 +874,16 @@ type TranslationSet struct { Switching string RemoveWorktree string RemoveWorktreeTitle string + RemoveWorktreeMenuTitle string + RemoveWorktreeAndDeleteBranch string + RemoveWorktreeAndDeleteBothBranches string + WorktreeNotCheckedOutOnBranch string DetachWorktree string + DetachWorktreeAndDeleteBranch string + DetachWorktreeAndDeleteBothBranches string DetachingWorktree string WorktreesTitle string WorktreeTitle string - RemoveWorktreePrompt string ForceRemoveWorktreePrompt string RemovingWorktree string AddingWorktree string @@ -2018,10 +2023,15 @@ func EnglishTranslationSet() *TranslationSet { Switching: "Switching", RemoveWorktree: "Remove worktree", RemoveWorktreeTitle: "Remove worktree", - RemoveWorktreePrompt: "Are you sure you want to remove worktree '{{.worktreeName}}'?", + RemoveWorktreeMenuTitle: "Remove worktree '{{.worktreeName}}'?", + RemoveWorktreeAndDeleteBranch: "Remove worktree and delete branch", + RemoveWorktreeAndDeleteBothBranches: "Remove worktree and delete local and remote branch", + WorktreeNotCheckedOutOnBranch: "This worktree is not checked out on a branch", ForceRemoveWorktreePrompt: "'{{.worktreeName}}' contains modified or untracked files, or submodules (or all of these). Are you sure you want to remove it?", RemovingWorktree: "Deleting worktree", DetachWorktree: "Detach worktree", + DetachWorktreeAndDeleteBranch: "Detach worktree and delete branch", + DetachWorktreeAndDeleteBothBranches: "Detach worktree and delete local and remote branch", DetachingWorktree: "Detaching worktree", AddingWorktree: "Adding worktree", CantDeleteCurrentWorktree: "You cannot remove the current worktree!", diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 136cd579b..380aca2b6 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -520,6 +520,9 @@ var tests = []*components.IntegrationTest{ worktree.LocationCandidates, worktree.NewWorktreePicker, worktree.NewWorktreePickerRemote, + worktree.RemoveWorktreeAndBothBranches, + worktree.RemoveWorktreeAndBranch, + worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch, worktree.RemoveWorktreeFromBranch, worktree.ResetWindowTabs, worktree.SymlinkIntoRepoSubdir, diff --git a/pkg/integration/tests/worktree/crud.go b/pkg/integration/tests/worktree/crud.go index 6cda94141..9a35d6cf7 100644 --- a/pkg/integration/tests/worktree/crud.go +++ b/pkg/integration/tests/worktree/crud.go @@ -106,9 +106,9 @@ var Crud = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("linked-worktree")). Press(keys.Universal.Remove). Tap(func() { - t.ExpectPopup().Confirmation(). - Title(Equals("Remove worktree")). - Content(Contains("Are you sure you want to remove worktree 'linked-worktree'?")). + t.ExpectPopup().Menu(). + Title(Equals("Remove worktree 'linked-worktree'?")). + Select(MatchesRegexp("Remove worktree$")). Confirm() }). Lines( diff --git a/pkg/integration/tests/worktree/detach_worktree_from_branch.go b/pkg/integration/tests/worktree/detach_worktree_from_branch.go index acd40e6ad..b36b89349 100644 --- a/pkg/integration/tests/worktree/detach_worktree_from_branch.go +++ b/pkg/integration/tests/worktree/detach_worktree_from_branch.go @@ -6,7 +6,7 @@ import ( ) var DetachWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Detach a worktree from the branches view", + Description: "Delete a branch that's checked out in another worktree by detaching that worktree", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, @@ -37,12 +37,12 @@ var DetachWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Branch newbranch is checked out by worktree linked-worktree")). - Select(Equals("Detach worktree")). + Select(Contains("Detach worktree and delete branch")). Confirm() }). + // The branch is gone; the worktree stays around (now detached) Lines( - Contains("mybranch"), - Contains("newbranch").DoesNotContain("(worktree)").IsSelected(), + Contains("mybranch").IsSelected(), ) t.Views().Worktrees(). diff --git a/pkg/integration/tests/worktree/force_remove_worktree.go b/pkg/integration/tests/worktree/force_remove_worktree.go index cde9e9da3..3fafa9755 100644 --- a/pkg/integration/tests/worktree/force_remove_worktree.go +++ b/pkg/integration/tests/worktree/force_remove_worktree.go @@ -29,9 +29,9 @@ var ForceRemoveWorktree = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("linked-worktree")). Press(keys.Universal.Remove). Tap(func() { - t.ExpectPopup().Confirmation(). - Title(Equals("Remove worktree")). - Content(Equals("Are you sure you want to remove worktree 'linked-worktree'?")). + t.ExpectPopup().Menu(). + Title(Equals("Remove worktree 'linked-worktree'?")). + Select(MatchesRegexp("Remove worktree$")). Confirm() t.ExpectPopup().Confirmation(). diff --git a/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go b/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go index 4af533e02..82e5a9303 100644 --- a/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go +++ b/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go @@ -29,9 +29,9 @@ var ForceRemoveWorktreeWithSubmodules = NewIntegrationTest(NewIntegrationTestArg NavigateToLine(Contains("linked-worktree")). Press(keys.Universal.Remove). Tap(func() { - t.ExpectPopup().Confirmation(). - Title(Equals("Remove worktree")). - Content(Equals("Are you sure you want to remove worktree 'linked-worktree'?")). + t.ExpectPopup().Menu(). + Title(Equals("Remove worktree 'linked-worktree'?")). + Select(MatchesRegexp("Remove worktree$")). Confirm() t.ExpectPopup().Confirmation(). diff --git a/pkg/integration/tests/worktree/remove_worktree_and_both_branches.go b/pkg/integration/tests/worktree/remove_worktree_and_both_branches.go new file mode 100644 index 000000000..8ba8e9112 --- /dev/null +++ b/pkg/integration/tests/worktree/remove_worktree_and_both_branches.go @@ -0,0 +1,64 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RemoveWorktreeAndBothBranches = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "From the worktrees panel, remove a worktree and delete both its local and remote branch in one go", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CloneIntoRemote("origin") + shell.EmptyCommit("initial commit") + shell.NewBranch("mybranch") + shell.EmptyCommit("commit on mybranch") + shell.PushBranchAndSetUpstream("origin", "mybranch") + shell.EmptyCommit("commit not pushed to the remote") // so mybranch isn't fully merged + shell.Checkout("master") + shell.AddWorktreeCheckout("mybranch", "../linked-worktree") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Worktrees(). + Focus(). + Lines( + Contains("(main worktree)").IsSelected(), + Contains("linked-worktree"), + ). + NavigateToLine(Contains("linked-worktree")). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Remove worktree 'linked-worktree'?")). + Select(Contains("Remove worktree and delete local and remote branch")). + Confirm() + + // mybranch isn't fully merged, so we get the force-delete warning + t.ExpectPopup().Confirmation(). + Title(Equals("Force delete branch")). + Content(Equals("'mybranch' is not fully merged. Are you sure you want to delete it?")). + Confirm() + }). + Lines( + Contains("(main worktree)").IsSelected(), + ) + + // The remote branch is gone too + t.Views().Remotes(). + Focus(). + Lines(Contains("origin")). + PressEnter() + + t.Views().RemoteBranches(). + IsEmpty() + + // And so is the local branch + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/worktree/remove_worktree_and_branch.go b/pkg/integration/tests/worktree/remove_worktree_and_branch.go new file mode 100644 index 000000000..5910b6b7f --- /dev/null +++ b/pkg/integration/tests/worktree/remove_worktree_and_branch.go @@ -0,0 +1,73 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RemoveWorktreeAndBranch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "From the worktrees panel, remove a worktree and delete its branch in one go", + 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.NewBranch("newbranch") + shell.EmptyCommit("commit on newbranch") + shell.Checkout("mybranch") + shell.AddWorktreeCheckout("newbranch", "../linked-worktree") + shell.RunCommand([]string{"git", "worktree", "add", "--detach", "../detached-worktree"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Worktrees(). + Focus(). + Lines( + Contains("(main worktree)").IsSelected(), + Contains("detached-worktree"), + Contains("linked-worktree"), + ). + // A detached worktree has no branch, so neither delete action is offered + NavigateToLine(Contains("detached-worktree")). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Remove worktree 'detached-worktree'?")). + Select(Contains("Remove worktree and delete branch")). + Tooltip(Contains("This worktree is not checked out on a branch")). + Select(Contains("Remove worktree and delete local and remote branch")). + Tooltip(Contains("This worktree is not checked out on a branch")). + Cancel() + }). + // Remove a worktree and delete its branch at once. newbranch has no + // upstream, so deleting the remote branch too isn't offered. + NavigateToLine(Contains("linked-worktree")). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Remove worktree 'linked-worktree'?")). + Select(Contains("Remove worktree and delete local and remote branch")). + Tooltip(Contains("The selected branch has no upstream")). + Select(Contains("Remove worktree and delete branch")). + Confirm() + + // newbranch isn't fully merged, so we get the force-delete warning + t.ExpectPopup().Confirmation(). + Title(Equals("Force delete branch")). + Content(Equals("'newbranch' is not fully merged. Are you sure you want to delete it?")). + Confirm() + }). + Lines( + Contains("(main worktree)"), + Contains("detached-worktree"), + ) + + // The branch is gone too + t.Views().Branches(). + Focus(). + Lines( + Contains("mybranch").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/worktree/remove_worktree_and_delete_local_and_remote_branch.go b/pkg/integration/tests/worktree/remove_worktree_and_delete_local_and_remote_branch.go new file mode 100644 index 000000000..09fae45eb --- /dev/null +++ b/pkg/integration/tests/worktree/remove_worktree_and_delete_local_and_remote_branch.go @@ -0,0 +1,71 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RemoveWorktreeAndDeleteLocalAndRemoteBranch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Delete the local branch, the remote branch, and the worktree of a single branch checked out in another worktree, all at once", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CloneIntoRemote("origin") + shell.EmptyCommit("initial commit") + shell.NewBranch("mybranch") + shell.EmptyCommit("commit on mybranch") + shell.PushBranchAndSetUpstream("origin", "mybranch") + shell.EmptyCommit("commit not pushed to the remote") // so mybranch isn't fully merged + shell.Checkout("master") + shell.AddWorktreeCheckout("mybranch", "../linked-worktree") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + Contains("mybranch (worktree linked-worktree)"), + ). + NavigateToLine(Contains("mybranch")). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Delete branch 'mybranch'?")). + Select(Contains("Delete local and remote branch")). + Confirm() + }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Branch mybranch is checked out by worktree linked-worktree")). + Select(Contains("Remove worktree and delete local and remote branch")). + Confirm() + + // mybranch is not contained in master, so we get the force-delete warning + t.ExpectPopup().Confirmation(). + Title(Equals("Force delete branch")). + Content(Equals("'mybranch' is not fully merged. Are you sure you want to delete it?")). + Confirm() + }). + // The local branch is gone + Lines( + Contains("master").IsSelected(), + ) + + // The remote branch is gone too + t.Views().Remotes(). + Focus(). + Lines(Contains("origin")). + PressEnter() + + t.Views().RemoteBranches(). + IsEmpty() + + // And so is the worktree + t.Views().Worktrees(). + Focus(). + Lines( + Contains("(main worktree)").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/worktree/remove_worktree_from_branch.go b/pkg/integration/tests/worktree/remove_worktree_from_branch.go index 1aa9645f3..7823af54c 100644 --- a/pkg/integration/tests/worktree/remove_worktree_from_branch.go +++ b/pkg/integration/tests/worktree/remove_worktree_from_branch.go @@ -6,7 +6,7 @@ import ( ) var RemoveWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Remove a worktree from the branches view", + Description: "Delete a branch that's checked out in another worktree by removing that worktree", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, @@ -38,22 +38,18 @@ var RemoveWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Branch newbranch is checked out by worktree linked-worktree")). - Select(Equals("Remove worktree")). - Confirm() - - t.ExpectPopup().Confirmation(). - Title(Equals("Remove worktree")). - Content(Equals("Are you sure you want to remove worktree 'linked-worktree'?")). + Select(Contains("Remove worktree and delete branch")). Confirm() + // The worktree is dirty, so we get asked to force-remove it t.ExpectPopup().Confirmation(). Title(Equals("Remove worktree")). Content(Equals("'linked-worktree' contains modified or untracked files, or submodules (or all of these). Are you sure you want to remove it?")). Confirm() }). + // The branch is gone, not just unlinked from its worktree Lines( - Contains("mybranch"), - Contains("newbranch").DoesNotContain("(worktree)").IsSelected(), + Contains("mybranch").IsSelected(), ) t.Views().Worktrees().