[mike@mwxm4]
This commit is contained in:
+184
@@ -0,0 +1,184 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user