Files
mgsh/deleteremote.go
T
2026-08-11 11:40:32 +02:00

185 lines
6.1 KiB
Go

package main
// deleteremote.go — the `deleteremote` command: remove the active project's
// repository from a public mirror server via that server's REST API.
//
// This is the only mgsh command that destroys something on a server it does not
// own, and nothing here can undo it. Four things keep it in check:
//
// - the server has to be named. `pushremote` without one means "every
// configured target", which is convenient there and catastrophic here.
// - the repository is looked up first, so a typo comes back as "not on that
// server" instead of as a DELETE nobody meant to send.
// - every deletion is confirmed on its own, spelling out owner/repo and the
// server it lives on, and the default answer is no.
// - the local clone is never touched. Only the git remote `pushremote`
// created is dropped, and only while it still points at what was deleted.
import (
"fmt"
"strings"
)
// deleteRepo removes owner/repo from the server.
//
// The providers disagree on what success looks like: Gitea and GitHub answer
// 204, GitLab 202 — there the deletion is merely scheduled, and depending on
// the plan the project stays visible until its retention period is over.
func (r *remoteAPI) deleteRepo(owner, repo string) error {
code, data, err := r.do("DELETE", r.repoPath(owner, repo), nil)
if err != nil {
return err
}
switch code {
case 200, 202, 204:
return nil
case 403:
// the common failure by far: the token can push and create, but was
// never given the separate permission a deletion needs
return fmt.Errorf("not allowed to delete %s/%s — the token needs %s (HTTP 403): %s",
owner, repo, r.deletePermission(), firstLine(data))
default:
return fmt.Errorf("deleting repository failed (HTTP %d): %s", code, firstLine(data))
}
}
// deletePermission names what a provider wants from a token before it will
// delete a repository, because a bare "403" sends people to the wrong setting.
func (r *remoteAPI) deletePermission() string {
switch r.kind {
case kindGitHub:
return "the 'delete_repo' scope"
case kindGitLab:
return "the 'api' scope and the Owner role"
default: // Gitea
return "the 'write:repository' scope"
}
}
// parseRemoteSelectors splits `deleteremote <name|host> ...` into the targets
// it names. The '@' that `pushremote` requires is accepted but optional here:
// the command takes nothing but selectors, so there is no description a bare
// word could be mistaken for.
func parseRemoteSelectors(args string) []string {
var out []string
for _, f := range strings.Fields(args) {
if s := strings.TrimPrefix(f, "@"); s != "" {
out = append(out, s)
}
}
return out
}
// handleDeleteRemote implements `deleteremote <@name|host> ...`: it deletes the
// active project's repository on each named mirror server.
func handleDeleteRemote(args string) {
if !requireProject() {
return
}
targets, incomplete := cfg.mirrorTargets()
for _, n := range incomplete {
errorln("remote " + n + ": url or key missing — skipped")
}
if len(targets) == 0 {
errorln("deleteremote needs a 'remote.<name>.url' and 'remote.<name>.key' in " + configFile())
return
}
// no selector is never "all of them" — that is the whole point of the
// command taking one
sels := parseRemoteSelectors(args)
if len(sels) == 0 {
errorln("deleteremote needs the server to delete from: " + remoteChoices(targets))
return
}
targets = pickRemotes(targets, sels)
if len(targets) == 0 {
return // pickRemotes already named the selectors it did not recognise
}
repo := PRJ // like pushremote: the project names the repository
done := 0
for _, t := range targets {
if deleteOnRemote(t, repo) {
done++
}
}
if len(targets) > 1 {
fmt.Printf("%s %d/%d repositories deleted\n", col(cDark, "deleteremote:"), done, len(targets))
}
}
// remoteChoices lists the configured targets the way they may be selected, for
// the message a `deleteremote` without a target earns.
func remoteChoices(targets []RemoteTarget) string {
out := make([]string, 0, len(targets))
for _, t := range targets {
if h := remoteHost(t.URL); h != "" && !strings.EqualFold(h, t.Name) {
out = append(out, "@"+t.Name+" ("+h+")")
continue
}
out = append(out, "@"+t.Name)
}
return strings.Join(out, ", ")
}
// deleteOnRemote deletes repo on one target, after asking. It reports whether
// something was actually deleted — a repository that is not there, and a
// question answered with no, are not failures, and neither stops the remaining
// targets.
func deleteOnRemote(t RemoteTarget, repo string) bool {
api := newRemoteAPI(t.URL, t.Key, t.Type)
owner, err := api.authUser()
if err != nil {
errorln(t.Name + ": " + err.Error())
return false
}
fmt.Printf("%s %s %s (as %s)\n",
col(cDark, "remote"), col(cYellow, t.Name), col(cBlue, api.url), col(cGreen, owner))
exists, err := api.repoExists(owner, repo)
if err != nil {
errorln(t.Name + ": " + err.Error())
return false
}
if !exists {
fmt.Println(col(cDark, "no repository "+owner+"/"+repo+" there — nothing to delete"))
return false
}
fmt.Println(col(cOrange, "this deletes "+api.repoWebURL(owner, repo)+
" with its issues, releases and history, and cannot be undone"))
if !yesno("delete "+owner+"/"+repo+" on "+t.Name+"?", false) {
fmt.Println(col(cDark, "kept"))
return false
}
if err := api.deleteRepo(owner, repo); err != nil {
errorln(t.Name + ": " + err.Error())
return false
}
fmt.Println(col(cRed, "deleted ") + col(cWhite, owner+"/"+repo) + col(cDark, " on "+t.Name))
dropGitRemote(t.Name, api.repoWebURL(owner, repo))
return true
}
// dropGitRemote removes the local git remote named name, but only while it
// still points at web — the repository just deleted. A remote the user has
// since re-aimed somewhere else is theirs, not ours, and a `git push` failing
// against a repository that no longer exists is worse than no remote at all.
func dropGitRemote(name, web string) {
if !isDir(DIR + "/.git") {
return
}
url, err := gitCapture(DIR, "remote", "get-url", name)
if err != nil || strings.TrimSpace(url) != web {
return
}
if gitOK(DIR, "remote", "remove", name) {
fmt.Println(col(cDark, "removed git remote "+name))
}
}