Files
mgsh/remote.go
T
2026-07-28 15:25:54 +02:00

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(cDark, "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(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.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(cBlue, 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
}