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 ...` 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..url' and 'remote..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)) } }