diff --git a/pkg/commands/git_commands/remote.go b/pkg/commands/git_commands/remote.go index 3b27730fc..b4c6228ea 100644 --- a/pkg/commands/git_commands/remote.go +++ b/pkg/commands/git_commands/remote.go @@ -59,9 +59,10 @@ func (self *RemoteCommands) DeleteRemoteBranch(task gocui.Task, remoteName strin return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() } -func (self *RemoteCommands) DeleteRemoteTag(task gocui.Task, remoteName string, tagName string) error { +func (self *RemoteCommands) DeleteRemoteTag(task gocui.Task, remoteName string, tagNames []string) error { cmdArgs := NewGitCmd("push"). - Arg(remoteName, "--delete", "refs/tags/"+tagName). + Arg(remoteName, "--delete"). + Arg(lo.Map(tagNames, func(t string, _ int) string { return "refs/tags/" + t })...). ToArgv() return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() diff --git a/pkg/commands/git_commands/tag.go b/pkg/commands/git_commands/tag.go index c6fe2e807..f559ca962 100644 --- a/pkg/commands/git_commands/tag.go +++ b/pkg/commands/git_commands/tag.go @@ -5,6 +5,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/samber/lo" ) type TagCommands struct { @@ -46,15 +47,16 @@ func (self *TagCommands) HasTag(tagName string) bool { return self.cmd.New(cmdArgs).DontLog().Run() == nil } -func (self *TagCommands) LocalDelete(tagName string) error { - cmdArgs := NewGitCmd("tag").Arg("-d", tagName). +func (self *TagCommands) LocalDelete(tagNames []string) error { + cmdArgs := NewGitCmd("tag").Arg("-d").Arg(tagNames...). ToArgv() return self.cmd.New(cmdArgs).Run() } -func (self *TagCommands) Push(task gocui.Task, remoteName string, tagName string) error { - cmdArgs := NewGitCmd("push").Arg(remoteName, "tag", tagName). +func (self *TagCommands) Push(task gocui.Task, remoteName string, tagNames []string) error { + cmdArgs := NewGitCmd("push").Arg(remoteName). + Arg(lo.FlatMap(tagNames, func(t string, _ int) []string { return []string{"tag", t} })...). 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 3006ba638..5f26c8099 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -504,18 +504,7 @@ func (self *BranchesHelper) PrepareFastForward(branches []*models.Branch) (func( // Runs f on a worker with all the given branches shown as being in the given // operation while it runs func (self *BranchesHelper) WithInlineStatusOnBranches(branches []*models.Branch, operation types.ItemOperation, f func(gocui.Task) error) error { - return self.c.WithInlineStatus(branches[0], operation, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { - for _, branch := range branches[1:] { - self.c.State().SetItemOperation(branch, operation) - } - defer func() { - for _, branch := range branches[1:] { - self.c.State().ClearItemOperation(branch) - } - }() - - return f(task) - }) + return WithInlineStatusOnItems(self.c, branches, operation, context.LOCAL_BRANCHES_CONTEXT_KEY, f) } func (self *BranchesHelper) fetchUpstreamBranches(task gocui.Task, branches []*models.Branch) error { diff --git a/pkg/gui/controllers/helpers/inline_status_helper.go b/pkg/gui/controllers/helpers/inline_status_helper.go index 814a11406..48c545bc7 100644 --- a/pkg/gui/controllers/helpers/inline_status_helper.go +++ b/pkg/gui/controllers/helpers/inline_status_helper.go @@ -94,6 +94,23 @@ func (self *InlineStatusHelper) WithInlineStatus(opts InlineStatusOpts, f func(g } } +// Runs f on a worker with all the given items shown as being in the given +// operation while it runs +func WithInlineStatusOnItems[T types.HasUrn](c *HelperCommon, items []T, operation types.ItemOperation, contextKey types.ContextKey, f func(gocui.Task) error) error { + return c.WithInlineStatus(items[0], operation, contextKey, func(task gocui.Task) error { + for _, item := range items[1:] { + c.State().SetItemOperation(item, operation) + } + defer func() { + for _, item := range items[1:] { + c.State().ClearItemOperation(item) + } + }() + + return f(task) + }) +} + func (self *InlineStatusHelper) start(opts InlineStatusOpts) { self.c.State().SetItemOperation(opts.Item, opts.Operation) diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go index a6c0e7e14..33e932013 100644 --- a/pkg/gui/controllers/tags_controller.go +++ b/pkg/gui/controllers/tags_controller.go @@ -7,6 +7,7 @@ import ( "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/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" @@ -61,17 +62,17 @@ func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types. }, { Keys: opts.GetKeys(opts.Config.Universal.Remove), - Handler: self.withItem(self.delete), + Handler: self.withItems(self.delete), Description: self.c.Tr.Delete, - GetDisabledReason: self.require(self.singleItemSelected()), + GetDisabledReason: self.require(self.itemsSelected()), Tooltip: self.c.Tr.TagDeleteTooltip, OpensMenu: true, DisplayOnScreen: true, }, { Keys: opts.GetKeys(opts.Config.Branches.PushTag), - Handler: self.withItem(self.push), - GetDisabledReason: self.require(self.singleItemSelected()), + Handler: self.withItems(self.push), + GetDisabledReason: self.require(self.itemsSelected()), Description: self.c.Tr.PushTag, Tooltip: self.c.Tr.PushTagTooltip, DisplayOnScreen: true, @@ -164,54 +165,104 @@ func (self *TagsController) checkout(tag *models.Tag) error { return nil } -func (self *TagsController) localDelete(tag *models.Tag) error { +func (self *TagsController) localDelete(tags []*models.Tag) error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag) - err := self.c.Git().Tag.LocalDelete(tag.Name) - self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + err := self.c.Git().Tag.LocalDelete(tagNames(tags)) + self.refreshAfterLocalDelete() return err }) } -func (self *TagsController) remoteDelete(tag *models.Tag) error { - title := utils.ResolvePlaceholderString( - self.c.Tr.SelectRemoteTagUpstream, - map[string]string{ - "tagName": tag.Name, +func (self *TagsController) remoteDelete(tags []*models.Tag) error { + confirmPromptTemplate := lo.Ternary(len(tags) > 1, self.c.Tr.DeleteRemoteTagsPrompt, self.c.Tr.DeleteRemoteTagPrompt) + return self.confirmRemoteDelete(tags, confirmPromptTemplate, func(task gocui.Task, upstream string) error { + self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag) + if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tagNames(tags)); err != nil { + return err + } + self.c.Toast(lo.Ternary(len(tags) > 1, self.c.Tr.RemoteTagsDeletedMessage, self.c.Tr.RemoteTagDeletedMessage)) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + return nil + }) +} + +func (self *TagsController) localAndRemoteDelete(tags []*models.Tag) error { + confirmPromptTemplate := lo.Ternary(len(tags) > 1, self.c.Tr.DeleteLocalAndRemoteTagsPrompt, self.c.Tr.DeleteLocalAndRemoteTagPrompt) + return self.confirmRemoteDelete(tags, confirmPromptTemplate, func(task gocui.Task, upstream string) error { + self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag) + if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tagNames(tags)); err != nil { + return err + } + + self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag) + if err := self.c.Git().Tag.LocalDelete(tagNames(tags)); err != nil { + return err + } + self.refreshAfterLocalDelete() + return nil + }) +} + +// Refreshes after deleting local tags. If the tags were a range selection, +// this also collapses it to its first line; otherwise it would select the tags +// that moved up into the place of the deleted ones. Collapsing it in the Then +// of a batched refresh draws the shorter list and the new selection in the +// same frame. +func (self *TagsController) refreshAfterLocalDelete() { + self.c.RefreshFromWorker(types.RefreshOptions{ + Scope: []types.RefreshableView{types.COMMITS, types.TAGS}, + BatchUIUpdates: true, + Then: func() error { + if self.context().IsSelectingRange() { + self.context().CollapseRangeSelectionToTop() + self.c.PostRefreshUpdate(self.context()) + } + return nil }, - ) + }) +} + +// Asks for the remote to delete the tags from and for a confirmation, and then +// runs deleteTags on a worker with the tags shown as being deleted. +// confirmPromptTemplate can use the placeholder upstream, and tagName if there +// is only one tag. +func (self *TagsController) confirmRemoteDelete( + tags []*models.Tag, + confirmPromptTemplate string, + deleteTags func(task gocui.Task, upstream string) error, +) error { + var title string + if len(tags) == 1 { + title = utils.ResolvePlaceholderString( + self.c.Tr.SelectRemoteTagUpstream, + map[string]string{ + "tagName": tags[0].Name, + }, + ) + } else { + title = self.c.Tr.SelectRemoteTagsUpstream + } self.c.Prompt(types.PromptOpts{ Title: title, InitialContent: "origin", FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(), HandleConfirm: func(upstream string) error { - confirmTitle := utils.ResolvePlaceholderString( - self.c.Tr.DeleteTagTitle, - map[string]string{ - "tagName": tag.Name, - }, - ) confirmPrompt := utils.ResolvePlaceholderString( - self.c.Tr.DeleteRemoteTagPrompt, + confirmPromptTemplate, map[string]string{ - "tagName": tag.Name, + "tagName": tags[0].Name, "upstream": upstream, }, ) self.c.Confirm(types.ConfirmOpts{ - Title: confirmTitle, + Title: self.deleteTagsTitle(tags), Prompt: confirmPrompt, HandleConfirm: func() error { - return self.c.WithInlineStatus(tag, types.ItemOperationDeleting, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error { - self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag) - if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tag.Name); err != nil { - return err - } - self.c.Toast(self.c.Tr.RemoteTagDeletedMessage) - self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) - return nil + return helpers.WithInlineStatusOnItems(self.c.HelperCommon, tags, types.ItemOperationDeleting, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error { + return deleteTags(task, upstream) }) }, }) @@ -223,116 +274,73 @@ func (self *TagsController) remoteDelete(tag *models.Tag) error { return nil } -func (self *TagsController) localAndRemoteDelete(tag *models.Tag) error { - title := utils.ResolvePlaceholderString( - self.c.Tr.SelectRemoteTagUpstream, - map[string]string{ - "tagName": tag.Name, - }, - ) - - self.c.Prompt(types.PromptOpts{ - Title: title, - InitialContent: "origin", - FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(), - HandleConfirm: func(upstream string) error { - confirmTitle := utils.ResolvePlaceholderString( - self.c.Tr.DeleteTagTitle, - map[string]string{ - "tagName": tag.Name, - }, - ) - confirmPrompt := utils.ResolvePlaceholderString( - self.c.Tr.DeleteLocalAndRemoteTagPrompt, - map[string]string{ - "tagName": tag.Name, - "upstream": upstream, - }, - ) - - self.c.Confirm(types.ConfirmOpts{ - Title: confirmTitle, - Prompt: confirmPrompt, - HandleConfirm: func() error { - return self.c.WithInlineStatus(tag, types.ItemOperationDeleting, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error { - self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag) - if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tag.Name); err != nil { - return err - } - - self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag) - if err := self.c.Git().Tag.LocalDelete(tag.Name); err != nil { - return err - } - self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) - return nil - }) - }, - }) - - return nil - }, - }) - - return nil -} - -func (self *TagsController) delete(tag *models.Tag) error { - menuTitle := utils.ResolvePlaceholderString( - self.c.Tr.DeleteTagTitle, - map[string]string{ - "tagName": tag.Name, - }, - ) - +func (self *TagsController) delete(tags []*models.Tag) error { menuItems := []*types.MenuItem{ { - Label: self.c.Tr.DeleteLocalTag, + Label: lo.Ternary(len(tags) > 1, self.c.Tr.DeleteLocalTags, self.c.Tr.DeleteLocalTag), Keys: menuKey('c'), OnPress: func() error { - return self.localDelete(tag) + return self.localDelete(tags) }, }, { - Label: self.c.Tr.DeleteRemoteTag, + Label: lo.Ternary(len(tags) > 1, self.c.Tr.DeleteRemoteTags, self.c.Tr.DeleteRemoteTag), Keys: menuKey('r'), OpensMenu: true, OnPress: func() error { - return self.remoteDelete(tag) + return self.remoteDelete(tags) }, }, { - Label: self.c.Tr.DeleteLocalAndRemoteTag, + Label: lo.Ternary(len(tags) > 1, self.c.Tr.DeleteLocalAndRemoteTags, self.c.Tr.DeleteLocalAndRemoteTag), Keys: menuKey('b'), OpensMenu: true, OnPress: func() error { - return self.localAndRemoteDelete(tag) + return self.localAndRemoteDelete(tags) }, }, } return self.c.Menu(types.CreateMenuOptions{ - Title: menuTitle, + Title: self.deleteTagsTitle(tags), Items: menuItems, }) } -func (self *TagsController) push(tag *models.Tag) error { - title := utils.ResolvePlaceholderString( - self.c.Tr.PushTagTitle, +func (self *TagsController) deleteTagsTitle(tags []*models.Tag) string { + if len(tags) > 1 { + return self.c.Tr.DeleteTagsTitle + } + + return utils.ResolvePlaceholderString( + self.c.Tr.DeleteTagTitle, map[string]string{ - "tagName": tag.Name, + "tagName": tags[0].Name, }, ) +} + +func (self *TagsController) push(tags []*models.Tag) error { + var title string + if len(tags) == 1 { + title = utils.ResolvePlaceholderString( + self.c.Tr.PushTagTitle, + map[string]string{ + "tagName": tags[0].Name, + }, + ) + } else { + title = self.c.Tr.PushTagsTitle + } self.c.Prompt(types.PromptOpts{ Title: title, InitialContent: "origin", FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(), HandleConfirm: func(response string) error { - return self.c.WithInlineStatus(tag, types.ItemOperationPushing, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error { + return helpers.WithInlineStatusOnItems(self.c.HelperCommon, tags, types.ItemOperationPushing, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.PushTag) - return self.c.Git().Tag.Push(task, response, tag.Name) + return self.c.Git().Tag.Push(task, response, tagNames(tags)) }) }, }) @@ -354,3 +362,7 @@ func (self *TagsController) create() error { func (self *TagsController) context() *context.TagsContext { return self.c.Contexts().Tags } + +func tagNames(tags []*models.Tag) []string { + return lo.Map(tags, func(tag *models.Tag, _ int) string { return tag.Name }) +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 8e0bcb017..007038e35 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -609,14 +609,23 @@ type TranslationSet struct { LightweightTag string AnnotatedTag string DeleteTagTitle string + DeleteTagsTitle string DeleteLocalTag string + DeleteLocalTags string DeleteRemoteTag string + DeleteRemoteTags string DeleteLocalAndRemoteTag string + DeleteLocalAndRemoteTags string SelectRemoteTagUpstream string + SelectRemoteTagsUpstream string DeleteRemoteTagPrompt string + DeleteRemoteTagsPrompt string DeleteLocalAndRemoteTagPrompt string + DeleteLocalAndRemoteTagsPrompt string RemoteTagDeletedMessage string + RemoteTagsDeletedMessage string PushTagTitle string + PushTagsTitle string PushTag string PushTagTooltip string NewTag string @@ -1789,14 +1798,23 @@ func EnglishTranslationSet() *TranslationSet { AnnotatedTag: "Annotated tag", LightweightTag: "Lightweight tag", DeleteTagTitle: "Delete tag '{{.tagName}}'?", + DeleteTagsTitle: "Delete selected tags?", DeleteLocalTag: "Delete local tag", + DeleteLocalTags: "Delete local tags", DeleteRemoteTag: "Delete remote tag", + DeleteRemoteTags: "Delete remote tags", DeleteLocalAndRemoteTag: "Delete local and remote tag", + DeleteLocalAndRemoteTags: "Delete local and remote tags", RemoteTagDeletedMessage: "Remote tag deleted", + RemoteTagsDeletedMessage: "Remote tags deleted", SelectRemoteTagUpstream: "Remote from which to remove tag '{{.tagName}}':", + SelectRemoteTagsUpstream: "Remote from which to remove the selected tags:", DeleteRemoteTagPrompt: "Are you sure you want to delete the remote tag '{{.tagName}}' from '{{.upstream}}'?", + DeleteRemoteTagsPrompt: "Are you sure you want to delete the selected tags from '{{.upstream}}'?", DeleteLocalAndRemoteTagPrompt: "Are you sure you want to delete '{{.tagName}}' from both your machine and from '{{.upstream}}'?", + DeleteLocalAndRemoteTagsPrompt: "Are you sure you want to delete the selected tags from both your machine and from '{{.upstream}}'?", PushTagTitle: "Remote to push tag '{{.tagName}}' to:", + PushTagsTitle: "Remote to push the selected tags to:", // Using 'push tag' rather than just 'push' to disambiguate from a global push PushTag: "Push tag", PushTagTooltip: "Push the selected tag to a remote. You'll be prompted to select a remote.", diff --git a/pkg/integration/components/git.go b/pkg/integration/components/git.go index 1b07e5cf8..c444cd43a 100644 --- a/pkg/integration/components/git.go +++ b/pkg/integration/components/git.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" + "github.com/samber/lo" ) type Git struct { @@ -27,6 +28,19 @@ func (self *Git) RemoteTagDeleted(ref string, tagName string) *Git { }) } +// Asserts the names of all tags on the remote, in alphabetical order +func (self *Git) RemoteTagNames(remote string, expectedNames []string) *Git { + return self.expect([]string{"git", "ls-remote", "--tags", "--refs", remote}, func(output string) (bool, string) { + names := lo.FilterMap(strings.Split(output, "\n"), func(line string, _ int) (string, bool) { + _, ref, found := strings.Cut(line, "\t") + return strings.TrimPrefix(ref, "refs/tags/"), found + }) + actual := strings.Join(names, "\n") + expected := strings.Join(expectedNames, "\n") + return actual == expected, fmt.Sprintf("Expected the tags on %s to be\n%s\nbut got\n%s", remote, expected, actual) + }) +} + func (self *Git) assert(cmdArgs []string, expected string) *Git { self.expect(cmdArgs, func(output string) (bool, string) { return output == expected, fmt.Sprintf("Expected current branch name to be '%s', but got '%s'", expected, output) diff --git a/pkg/integration/tests/tag/delete_multiple.go b/pkg/integration/tests/tag/delete_multiple.go new file mode 100644 index 000000000..0cf3acd3a --- /dev/null +++ b/pkg/integration/tests/tag/delete_multiple.go @@ -0,0 +1,133 @@ +package tag + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DeleteMultiple = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Delete a range selection of tags remotely, locally and remotely, and locally", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial commit") + shell.CloneIntoRemote("origin") + + // The tags are sorted by date, newest first. Give each tag's commit a + // date of its own so that the order is fixed. + for i := 1; i <= 8; i++ { + shell.EmptyCommitWithDate(fmt.Sprintf("commit %02d", i), fmt.Sprintf("2024-01-%02d 10:00:00", i)) + shell.CreateLightweightTag(fmt.Sprintf("tag-%02d", i), "HEAD") + } + + for _, tag := range []string{"tag-03", "tag-04", "tag-05", "tag-06"} { + shell.PushBranch("origin", "refs/tags/"+tag) // abusing PushBranch to push a tag + } + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Tags(). + Focus(). + Lines( + Contains("tag-08").IsSelected(), + Contains("tag-07"), + Contains("tag-06"), + Contains("tag-05"), + Contains("tag-04"), + Contains("tag-03"), + Contains("tag-02"), + Contains("tag-01"), + ). + NavigateToLine(Contains("tag-06")). + Press(keys.Universal.RangeSelectDown). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup(). + Menu(). + Title(Equals("Delete selected tags?")). + Select(Contains("Delete remote tags")). + Confirm() + + t.ExpectPopup().Prompt(). + Title(Equals("Remote from which to remove the selected tags:")). + InitialText(Equals("origin")). + Confirm() + + t.ExpectPopup(). + Confirmation(). + Title(Equals("Delete selected tags?")). + Content(Equals("Are you sure you want to delete the selected tags from 'origin'?")). + Confirm() + + t.ExpectToast(Equals("Remote tags deleted")) + }). + // The local tags are still there, so the selection stays as it is + Lines( + Contains("tag-08"), + Contains("tag-07"), + Contains("tag-06").IsSelected(), + Contains("tag-05").IsSelected(), + Contains("tag-04"), + Contains("tag-03"), + Contains("tag-02"), + Contains("tag-01"), + ). + Tap(func() { + t.Git(). + RemoteTagDeleted("origin", "tag-06"). + RemoteTagDeleted("origin", "tag-05") + }). + NavigateToLine(Contains("tag-04")). + Press(keys.Universal.RangeSelectDown). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup(). + Menu(). + Title(Equals("Delete selected tags?")). + Select(Contains("Delete local and remote tags")). + Confirm() + + t.ExpectPopup().Prompt(). + Title(Equals("Remote from which to remove the selected tags:")). + InitialText(Equals("origin")). + Confirm() + + t.ExpectPopup(). + Confirmation(). + Title(Equals("Delete selected tags?")). + Content(Equals("Are you sure you want to delete the selected tags from both your machine and from 'origin'?")). + Confirm() + }). + Lines( + Contains("tag-08"), + Contains("tag-07"), + Contains("tag-06"), + Contains("tag-05"), + Contains("tag-02").IsSelected(), + Contains("tag-01"), + ). + Tap(func() { + t.Git(). + RemoteTagDeleted("origin", "tag-04"). + RemoteTagDeleted("origin", "tag-03") + }). + NavigateToLine(Contains("tag-07")). + Press(keys.Universal.RangeSelectDown). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup(). + Menu(). + Title(Equals("Delete selected tags?")). + Select(Contains("Delete local tags")). + Confirm() + }). + Lines( + Contains("tag-08"), + Contains("tag-05").IsSelected(), + Contains("tag-02"), + Contains("tag-01"), + ) + }, +}) diff --git a/pkg/integration/tests/tag/push_multiple.go b/pkg/integration/tests/tag/push_multiple.go new file mode 100644 index 000000000..d7a6b5eeb --- /dev/null +++ b/pkg/integration/tests/tag/push_multiple.go @@ -0,0 +1,53 @@ +package tag + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PushMultiple = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Push a range selection of tags", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial commit") + shell.CloneIntoRemote("origin") + + // The tags are sorted by date, newest first. Give each tag's commit a + // date of its own so that the order is fixed. + for i := 1; i <= 4; i++ { + shell.EmptyCommitWithDate(fmt.Sprintf("commit %02d", i), fmt.Sprintf("2024-01-%02d 10:00:00", i)) + shell.CreateLightweightTag(fmt.Sprintf("tag-%02d", i), "HEAD") + } + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Tags(). + Focus(). + Lines( + Contains("tag-04").IsSelected(), + Contains("tag-03"), + Contains("tag-02"), + Contains("tag-01"), + ). + NavigateToLine(Contains("tag-03")). + Press(keys.Universal.RangeSelectDown). + Press(keys.Branches.PushTag). + Tap(func() { + t.ExpectPopup().Prompt(). + Title(Equals("Remote to push the selected tags to:")). + InitialText(Equals("origin")). + Confirm() + }). + Lines( + Contains("tag-04"), + Contains("tag-03").IsSelected(), + Contains("tag-02").IsSelected(), + Contains("tag-01"), + ) + + t.Git().RemoteTagNames("origin", []string{"tag-02", "tag-03"}) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 7ac9055ab..372b82c8f 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -533,9 +533,11 @@ var tests = []*components.IntegrationTest{ tag.CrudAnnotated, tag.CrudLightweight, tag.DeleteLocalAndRemote, + tag.DeleteMultiple, tag.DeleteRemoteTagWhenBranchWithSameNameExists, tag.ForceTagAnnotated, tag.ForceTagLightweight, + tag.PushMultiple, tag.Reset, tag.ResetToDuplicateNamedBranch, ui.Accordion,