There were two spellings for the same thing -- a flat remoteurl/remotekey/remotetype/remotevisibility set for a single server, and remote.<name>.* blocks for several. The flat one is gone; every target, including a lone one, is now a named block with the fields url, key, type and visibility. An existing ~/.mgshrc is converted on the next start. Only the key is rewritten, so values, comments, alignment, commented-out lines and the file's 0600 mode survive untouched, and mgsh prints each rename rather than doing it quietly. The target is named "public", which is what the old settings called the git remote they created, so a converted setup keeps pushing to the same place under the same remote name. A file that carries both spellings keeps what the new one says. The environment follows the same shape: MGSH_REMOTEURL and friends are replaced by MGSH_REMOTE_<NAME>_<FIELD>, so MGSH_REMOTE_GITLAB_KEY sets remote.gitlab.key. The field is read from the end of the variable name, which leaves target names free to contain underscores. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
402 lines
11 KiB
Go
402 lines
11 KiB
Go
package main
|
|
|
|
// remote.go — the `pushremote` command: mirror the active project to a public
|
|
// git hosting server (Gitea, GitHub or GitLab), creating the repository via the
|
|
// server's REST API when it does not exist yet.
|
|
//
|
|
// Configuration (in ~/.mgshrc, a project .mgshrc, or MGSH_* env): one
|
|
// remote.<name>.<field> block per server, which `pushremote` mirrors to in turn.
|
|
//
|
|
// remote.gitlab.url = https://gitlab.example.com
|
|
// remote.gitlab.key = <api-token>
|
|
// remote.gitlab.type = gitlab optional; detected from the url
|
|
// remote.gitlab.visibility = public or private (the default)
|
|
// remotes = gitlab optional: restrict/order the set
|
|
//
|
|
// Each target owns a git remote of its own name in the repository. There is no
|
|
// second spelling: the pre-4.1 flat remoteurl/remotekey pair is migrated to
|
|
// remote.public.* on load.
|
|
//
|
|
// The token is used for the API calls and, via an HTTP Basic auth header, for
|
|
// the git push. It is never written into the repository's git config, and it is
|
|
// handed to git through the environment rather than the command line so it does
|
|
// not show up in the process table.
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type remoteKind int
|
|
|
|
const (
|
|
kindGitea remoteKind = iota
|
|
kindGitHub
|
|
kindGitLab
|
|
)
|
|
|
|
// detectRemoteKind picks the provider from an explicit override or the URL.
|
|
func detectRemoteKind(rawurl, override string) remoteKind {
|
|
switch strings.ToLower(strings.TrimSpace(override)) {
|
|
case "github":
|
|
return kindGitHub
|
|
case "gitlab":
|
|
return kindGitLab
|
|
case "gitea":
|
|
return kindGitea
|
|
}
|
|
u := strings.ToLower(rawurl)
|
|
switch {
|
|
case strings.Contains(u, "github.com") || strings.Contains(u, "api.github.com"):
|
|
return kindGitHub
|
|
case strings.Contains(u, "gitlab"):
|
|
return kindGitLab
|
|
default:
|
|
return kindGitea
|
|
}
|
|
}
|
|
|
|
// remoteAPI talks to one provider's REST API.
|
|
type remoteAPI struct {
|
|
kind remoteKind
|
|
url string // base URL, trailing slash trimmed
|
|
key string
|
|
http *http.Client
|
|
}
|
|
|
|
func newRemoteAPI(cfgURL, key, typ string) *remoteAPI {
|
|
return &remoteAPI{
|
|
kind: detectRemoteKind(cfgURL, typ),
|
|
url: strings.TrimRight(strings.TrimSpace(cfgURL), "/"),
|
|
key: strings.TrimSpace(key),
|
|
http: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
// apiRoot returns the REST API root for the provider.
|
|
func (r *remoteAPI) apiRoot() string {
|
|
switch r.kind {
|
|
case kindGitHub:
|
|
if r.url == "" || strings.Contains(r.url, "github.com") {
|
|
return "https://api.github.com"
|
|
}
|
|
return r.url + "/api/v3" // GitHub Enterprise
|
|
case kindGitLab:
|
|
return r.url + "/api/v4"
|
|
default: // Gitea
|
|
return r.url + "/api/v1"
|
|
}
|
|
}
|
|
|
|
// authHeader returns the header name/value used to authenticate API calls.
|
|
func (r *remoteAPI) authHeader() (string, string) {
|
|
switch r.kind {
|
|
case kindGitLab:
|
|
return "PRIVATE-TOKEN", r.key
|
|
case kindGitHub:
|
|
return "Authorization", "Bearer " + r.key
|
|
default: // Gitea
|
|
return "Authorization", "token " + r.key
|
|
}
|
|
}
|
|
|
|
func (r *remoteAPI) do(method, endpoint string, body any) (int, []byte, error) {
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequest(method, endpoint, rdr)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
hk, hv := r.authHeader()
|
|
req.Header.Set(hk, hv)
|
|
req.Header.Set("Accept", "application/json")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := r.http.Do(req)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, data, nil
|
|
}
|
|
|
|
// authUser returns the login/username of the token owner.
|
|
func (r *remoteAPI) authUser() (string, error) {
|
|
code, data, err := r.do("GET", r.apiRoot()+"/user", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if code != 200 {
|
|
return "", fmt.Errorf("authentication failed (HTTP %d): %s", code, firstLine(data))
|
|
}
|
|
var u struct {
|
|
Login string `json:"login"` // Gitea, GitHub
|
|
Username string `json:"username"` // GitLab
|
|
}
|
|
json.Unmarshal(data, &u)
|
|
if u.Login != "" {
|
|
return u.Login, nil
|
|
}
|
|
if u.Username != "" {
|
|
return u.Username, nil
|
|
}
|
|
return "", fmt.Errorf("could not determine remote user")
|
|
}
|
|
|
|
// repoPath is the API path of one repository. GitLab addresses a project by its
|
|
// URL-encoded "owner/repo" path, the others by two path elements.
|
|
func (r *remoteAPI) repoPath(owner, repo string) string {
|
|
if r.kind == kindGitLab {
|
|
return r.apiRoot() + "/projects/" + url.PathEscape(owner+"/"+repo)
|
|
}
|
|
return r.apiRoot() + "/repos/" + owner + "/" + repo
|
|
}
|
|
|
|
// repoExists reports whether owner/repo already exists on the server.
|
|
func (r *remoteAPI) repoExists(owner, repo string) (bool, error) {
|
|
code, data, err := r.do("GET", r.repoPath(owner, repo), nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
switch code {
|
|
case 200:
|
|
return true, nil
|
|
case 404:
|
|
return false, nil
|
|
default:
|
|
return false, fmt.Errorf("checking repository failed (HTTP %d): %s", code, firstLine(data))
|
|
}
|
|
}
|
|
|
|
// createRepo creates owner/repo on the server with the given visibility and
|
|
// (optional) description.
|
|
func (r *remoteAPI) createRepo(repo, description string, private bool) error {
|
|
var ep string
|
|
var body map[string]any
|
|
if r.kind == kindGitLab {
|
|
vis := "public"
|
|
if private {
|
|
vis = "private"
|
|
}
|
|
ep = r.apiRoot() + "/projects"
|
|
body = map[string]any{"name": repo, "visibility": vis}
|
|
} else { // Gitea + GitHub
|
|
ep = r.apiRoot() + "/user/repos"
|
|
body = map[string]any{"name": repo, "private": private}
|
|
}
|
|
if description != "" {
|
|
body["description"] = description
|
|
}
|
|
code, data, err := r.do("POST", ep, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if code != 200 && code != 201 {
|
|
return fmt.Errorf("creating repository failed (HTTP %d): %s", code, firstLine(data))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// repoWebURL returns the https clone/push URL (without credentials).
|
|
func (r *remoteAPI) repoWebURL(owner, repo string) string {
|
|
base := r.url
|
|
if r.kind == kindGitHub && (base == "" || strings.Contains(base, "api.github.com")) {
|
|
base = "https://github.com"
|
|
}
|
|
return base + "/" + owner + "/" + repo + ".git"
|
|
}
|
|
|
|
// parsePushRemoteArgs splits `pushremote [@name ...] [description]` into the
|
|
// selected target names and the description. The '@' sigil keeps the two apart:
|
|
// without it a description whose first word happens to name a remote would
|
|
// silently push somewhere else.
|
|
func parsePushRemoteArgs(args string) (names []string, description string) {
|
|
fields := strings.Fields(args)
|
|
i := 0
|
|
for ; i < len(fields); i++ {
|
|
if !strings.HasPrefix(fields[i], "@") {
|
|
break
|
|
}
|
|
if n := strings.TrimPrefix(fields[i], "@"); n != "" {
|
|
names = append(names, n)
|
|
}
|
|
}
|
|
return names, strings.Join(fields[i:], " ")
|
|
}
|
|
|
|
// pickRemotes narrows all to the explicitly requested names, complaining about
|
|
// any that are not configured. With no names given, all targets are used.
|
|
func pickRemotes(all []RemoteTarget, names []string) []RemoteTarget {
|
|
if len(names) == 0 {
|
|
return all
|
|
}
|
|
var out []RemoteTarget
|
|
seen := map[string]bool{}
|
|
for _, n := range names {
|
|
found := false
|
|
for _, t := range all {
|
|
if !strings.EqualFold(t.Name, n) {
|
|
continue
|
|
}
|
|
if !seen[t.Name] { // `@hub @hub` must not push twice
|
|
seen[t.Name] = true
|
|
out = append(out, t)
|
|
}
|
|
found = true
|
|
break
|
|
}
|
|
if !found {
|
|
errorln("unknown remote: " + n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// handlePushRemote implements `pushremote [@name ...] [description]`. Without
|
|
// a @name it mirrors to every configured target; the description, if given, is
|
|
// set on the repository when it is created.
|
|
func handlePushRemote(args string) {
|
|
if !requireRepo() {
|
|
return
|
|
}
|
|
names, description := parsePushRemoteArgs(args)
|
|
|
|
targets, incomplete := cfg.mirrorTargets()
|
|
for _, n := range incomplete {
|
|
errorln("remote " + n + ": url or key missing — skipped")
|
|
}
|
|
targets = pickRemotes(targets, names)
|
|
if len(targets) == 0 {
|
|
if len(names) == 0 { // an unknown @name already reported itself
|
|
errorln("pushremote needs a 'remote.<name>.url' and 'remote.<name>.key' in " + configFile())
|
|
}
|
|
return
|
|
}
|
|
|
|
repo := PRJ // requireRepo() guarantees a project, which names the repository
|
|
|
|
done := 0
|
|
for _, t := range targets {
|
|
if pushToRemote(t, repo, description) {
|
|
done++
|
|
}
|
|
}
|
|
if len(targets) > 1 {
|
|
fmt.Printf("%s %d/%d remotes updated\n", col(cGray, "pushremote:"), done, len(targets))
|
|
}
|
|
}
|
|
|
|
// pushToRemote mirrors the active project to one target, creating the
|
|
// repository when needed. It returns whether the push succeeded — a server that
|
|
// is down must not stop the remaining targets.
|
|
func pushToRemote(t RemoteTarget, repo, description string) bool {
|
|
private := !strings.EqualFold(strings.TrimSpace(t.Vis), "public")
|
|
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(cGray, "remote"), col(cYellow, t.Name), col(cCyan, api.url), col(cGreen, owner))
|
|
|
|
exists, err := api.repoExists(owner, repo)
|
|
if err != nil {
|
|
errorln(t.Name + ": " + err.Error())
|
|
return false
|
|
}
|
|
if exists {
|
|
fmt.Printf("repository %s exists\n", col(cGreen, owner+"/"+repo))
|
|
} else {
|
|
vis := "private"
|
|
if !private {
|
|
vis = "public"
|
|
}
|
|
fmt.Printf("creating %s repository %s ...\n", vis, col(cGreen, owner+"/"+repo))
|
|
if err := api.createRepo(repo, description, private); err != nil {
|
|
errorln(t.Name + ": " + err.Error())
|
|
return false
|
|
}
|
|
}
|
|
|
|
// keep a credential-free git remote named after the target
|
|
web := api.repoWebURL(owner, repo)
|
|
ensureGitRemote(t.Name, web)
|
|
|
|
header := api.pushHeader(owner)
|
|
if !gitPushHeader(DIR, t.Name, header, "--all") {
|
|
return false
|
|
}
|
|
gitPushHeader(DIR, t.Name, header, "--tags")
|
|
fmt.Println(col(cGreen, "pushed to ") + col(cCyan, web))
|
|
return true
|
|
}
|
|
|
|
// ensureGitRemote points a credential-free git remote named name at web,
|
|
// adding it when the repository does not have it yet.
|
|
func ensureGitRemote(name, web string) bool {
|
|
if _, err := gitCapture(DIR, "remote", "get-url", name); err == nil {
|
|
return gitOK(DIR, "remote", "set-url", name, web)
|
|
}
|
|
return gitOK(DIR, "remote", "add", name, web)
|
|
}
|
|
|
|
// pushHeader builds the one-shot HTTP Basic auth header used for pushes, so the
|
|
// token is neither persisted in the repository's git config nor visible in `ps`.
|
|
func (r *remoteAPI) pushHeader(owner string) string {
|
|
return "Authorization: Basic " +
|
|
base64.StdEncoding.EncodeToString([]byte(owner+":"+r.key))
|
|
}
|
|
|
|
// gitPushHeader runs `git push <remote> <args...>` with an extra HTTP auth
|
|
// header, disabling interactive credential prompts.
|
|
//
|
|
// The header carries the token, so it is passed through GIT_CONFIG_* rather
|
|
// than `-c http.extraHeader=...`: a command line is world-readable in the
|
|
// process table, an environment block is not.
|
|
func gitPushHeader(dir, remote, header string, args ...string) bool {
|
|
c := exec.Command("git", append([]string{"push", remote}, args...)...)
|
|
c.Dir = dir
|
|
c.Stdin = os.Stdin
|
|
c.Stdout = os.Stdout
|
|
c.Stderr = os.Stderr
|
|
c.Env = append(os.Environ(),
|
|
"GIT_TERMINAL_PROMPT=0",
|
|
"GIT_CONFIG_COUNT=1",
|
|
"GIT_CONFIG_KEY_0=http.extraHeader",
|
|
"GIT_CONFIG_VALUE_0="+header,
|
|
)
|
|
if err := c.Run(); err != nil {
|
|
errorln("git push " + remote + " " + strings.Join(args, " ") + " failed")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// firstLine trims an API response body to a short single-line summary.
|
|
func firstLine(b []byte) string {
|
|
s := strings.TrimSpace(string(b))
|
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
if len(s) > 200 {
|
|
s = s[:200]
|
|
}
|
|
return s
|
|
}
|