Files
mgsh/remote.go
T
mikeandClaude Opus 5 2a622046f2 Define mirror targets one way: remote.<name>.<field>
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>
2026-07-26 18:00:26 +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(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
}