Repository: jesseduffield/lazygit (language: go)

Issue:
An option to delete both local and remote tag at once
**Is your feature request related to a problem? Please describe.**
Sometimes you create and push a tag only to discover that your Github Action build/release fails beause of some ci/cd issue. When you fix the issue, you want to delete the old tag and re-apply it on the top of the fix commit. Currently you need to do it twice, once for remote and once for local. You also need to do it in the correct order.

**Describe the solution you'd like**
Add an new option in the tag delete dialog to delete both local and remote tags in one go.

**Describe alternatives you've considered**
N/A

**Additional context**
Similar feature for branches was recently implemented https://github.com/jesseduffield/lazygit/issues/4108 https://github.com/jesseduffield/lazygit/pull/3916



Discussion / hints:
Hello, @stefanhaller
I was looking through issues to try and resolve them and found this enhancement rather interesting and, maybe, a good place where I can start. And since no one answered here, I would like to give this issue a try and create a pull request.
Do you think this enhancement is worth it? If yes, I will go through the contributing guidelines and create a PR:)
I think such an option makes sense, given that we support it on branches. One difference with tags is that you'll still need to prompt the user for which remote to delete the tag from given that tags don't have a concept of a tracking branch like how branches do.

So feel free to raise a PR for this @AnvarU 

Fix patch:
diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go
index 372fa8e0a31..8a207478e48 100644
--- a/pkg/gui/controllers/tags_controller.go
+++ b/pkg/gui/controllers/tags_controller.go
@@ -177,6 +177,59 @@ 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
+						}
+						return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
+					})
+				},
+			})
+
+			return nil
+		},
+	})
+
+	return nil
+}
+
 func (self *TagsController) delete(tag *models.Tag) error {
 	menuTitle := utils.ResolvePlaceholderString(
 		self.c.Tr.DeleteTagTitle,
@@ -201,6 +254,14 @@ func (self *TagsController) delete(tag *models.Tag) error {
 				return self.remoteDelete(tag)
 			},
 		},
+		{
+			Label:     self.c.Tr.DeleteLocalAndRemoteTag,
+			Key:       'b',
+			OpensMenu: true,
+			OnPress: func() error {
+				return self.localAndRemoteDelete(tag)
+			},
+		},
 	}
 
 	return self.c.Menu(types.CreateMenuOptions{
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index 968aa5718fb..87744f24d71 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -518,8 +518,10 @@ type TranslationSet struct {
 	DeleteTagTitle                        string
 	DeleteLocalTag                        string
 	DeleteRemoteTag                       string
+	DeleteLocalAndRemoteTag               string
 	SelectRemoteTagUpstream               string
 	DeleteRemoteTagPrompt                 string
+	DeleteLocalAndRemoteTagPrompt         string
 	RemoteTagDeletedMessage               string
 	PushTagTitle                          string
 	PushTag                               string
@@ -1539,9 +1541,11 @@ func EnglishTranslationSet() *TranslationSet {
 		DeleteTagTitle:                       "Delete tag '{{.tagName}}'?",
 		DeleteLocalTag:                       "Delete local tag",
 		DeleteRemoteTag:                      "Delete remote tag",
+		DeleteLocalAndRemoteTag:              "Delete local and remote tag",
 		RemoteTagDeletedMessage:              "Remote tag deleted",
 		SelectRemoteTagUpstream:              "Remote from which to remove tag '{{.tagName}}':",
 		DeleteRemoteTagPrompt:                "Are you sure you want to delete the remote tag '{{.tagName}}' from '{{.upstream}}'?",
+		DeleteLocalAndRemoteTagPrompt:        "Are you sure you want to delete '{{.tagName}}' from both your machine and from '{{.upstream}}'?",
 		PushTagTitle:                         "Remote to push tag '{{.tagName}}' to:",
 		// Using 'push tag' rather than just 'push' to disambiguate from a global push
 		PushTag:                        "Push tag",
diff --git a/pkg/integration/components/shell.go b/pkg/integration/components/shell.go
index 4fb2d5f529e..faf58e64a55 100644
--- a/pkg/integration/components/shell.go
+++ b/pkg/integration/components/shell.go
@@ -190,6 +190,10 @@ func (self *Shell) Revert(ref string) *Shell {
 	return self.RunCommand([]string{"git", "revert", ref})
 }
 
+func (self *Shell) AssertRemoteTagNotFound(upstream, name string) *Shell {
+	return self.RunCommandExpectError([]string{"git", "ls-remote", "--exit-code", upstream, fmt.Sprintf("refs/tags/%s", name)})
+}
+
 func (self *Shell) CreateLightweightTag(name string, ref string) *Shell {
 	return self.RunCommand([]string{"git", "tag", name, ref})
 }


Test patch:
diff --git a/pkg/integration/tests/tag/delete_local_and_remote.go b/pkg/integration/tests/tag/delete_local_and_remote.go
new file mode 100644
index 00000000000..35b9bc25de1
--- /dev/null
+++ b/pkg/integration/tests/tag/delete_local_and_remote.go
@@ -0,0 +1,75 @@
+package tag
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var DeleteLocalAndRemote = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Create and delete both local and remote annotated tag",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		shell.EmptyCommit("initial commit")
+		shell.CloneIntoRemote("origin")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Tags().
+			Focus().
+			IsEmpty().
+			Press(keys.Universal.New).
+			Tap(func() {
+				t.ExpectPopup().CommitMessagePanel().
+					Title(Equals("Tag name")).
+					Type("new-tag").
+					SwitchToDescription().
+					Title(Equals("Tag description")).
+					Type("message").
+					SwitchToSummary().
+					Confirm()
+			}).
+			Lines(
+				MatchesRegexp(`new-tag.*message`).IsSelected(),
+			).
+			Press(keys.Universal.Push).
+			Tap(func() {
+				t.ExpectPopup().Prompt().
+					Title(Equals("Remote to push tag 'new-tag' to:")).
+					InitialText(Equals("origin")).
+					SuggestionLines(
+						Contains("origin"),
+					).
+					Confirm()
+			}).
+			Press(keys.Universal.Remove).
+			Tap(func() {
+				t.ExpectPopup().
+					Menu().
+					Title(Equals("Delete tag 'new-tag'?")).
+					Select(Contains("Delete local and remote tag")).
+					Confirm()
+			}).
+			Tap(func() {
+				t.ExpectPopup().Prompt().
+					Title(Equals("Remote from which to remove tag 'new-tag':")).
+					InitialText(Equals("origin")).
+					SuggestionLines(
+						Contains("origin"),
+					).
+					Confirm()
+			}).
+			Tap(func() {
+				t.ExpectPopup().
+					Confirmation().
+					Title(Equals("Delete tag 'new-tag'?")).
+					Content(Equals("Are you sure you want to delete 'new-tag' from both your machine and from 'origin'?")).
+					Confirm()
+			}).
+			IsEmpty().
+			Press(keys.Universal.New).
+			Tap(func() {
+				t.Shell().AssertRemoteTagNotFound("origin", "new-tag")
+			})
+	},
+})
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index 8b520e9e4ab..54a78bd1eb2 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -356,6 +356,7 @@ var tests = []*components.IntegrationTest{
 	tag.CreateWhileCommitting,
 	tag.CrudAnnotated,
 	tag.CrudLightweight,
+	tag.DeleteLocalAndRemote,
 	tag.ForceTagAnnotated,
 	tag.ForceTagLightweight,
 	tag.Reset,