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 or MGSH_* env): // // remoteurl = https://git.example.com base URL of the server // remotekey = personal access token // remotetype = gitea|github|gitlab optional; auto-detected from the URL // // 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. 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") } // repoExists reports whether owner/repo already exists on the server. func (r *remoteAPI) repoExists(owner, repo string) (bool, error) { var ep string if r.kind == kindGitLab { ep = r.apiRoot() + "/projects/" + url.PathEscape(owner+"/"+repo) } else { ep = r.apiRoot() + "/repos/" + owner + "/" + repo } code, data, err := r.do("GET", ep, 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" } // handlePushRemote implements the `pushremote [description]` command. The // description, if given, is set on the repository when it is created. func handlePushRemote(description string) { if !requireRepo() { return } if cfg.RemoteURL == "" || cfg.RemoteKey == "" { errorln("pushremote needs 'remoteurl' and 'remotekey' in ~/.mgshrc") return } repo := PRJ if repo == "" { repo = REPO } if repo == "" { errorln("cannot determine repository name") return } private := !strings.EqualFold(strings.TrimSpace(cfg.RemoteVis), "public") api := newRemoteAPI(cfg.RemoteURL, cfg.RemoteKey, cfg.RemoteType) owner, err := api.authUser() if err != nil { errorln(err.Error()) return } fmt.Printf("%s %s (as %s)\n", col(cGray, "remote"), col(cCyan, api.url), col(cGreen, owner)) exists, err := api.repoExists(owner, repo) if err != nil { errorln(err.Error()) return } 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(err.Error()) return } } // keep a credential-free remote named "public" for convenience web := api.repoWebURL(owner, repo) if _, err := gitCapture(DIR, "remote", "get-url", "public"); err == nil { gitOK(DIR, "remote", "set-url", "public", web) } else { gitOK(DIR, "remote", "add", "public", web) } // authenticate the push with a one-shot Basic auth header so the token is // never persisted in the repository's git config header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(owner+":"+api.key)) if !gitPushHeader(DIR, "public", header, "--all") { return } gitPushHeader(DIR, "public", header, "--tags") fmt.Println(col(cGreen, "pushed to ") + col(cCyan, web)) } // gitPushHeader runs `git push ` with an extra HTTP auth // header, disabling interactive credential prompts. func gitPushHeader(dir, remote, header string, args ...string) bool { full := append([]string{"-c", "http.extraHeader=" + header, "push", remote}, args...) c := exec.Command("git", full...) c.Dir = dir c.Stdin = os.Stdin c.Stdout = os.Stdout c.Stderr = os.Stderr c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") 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 }