Correctness and security fixes found by a review of the initial commit: - init decided whether a server repository existed from the *local* remote.origin.url, so an unlinked project directory skipped the confirmation and rm -rf'd the remote history. It now asks the server, and aborts when the server cannot be reached. - Project names and paths were interpolated unquoted into the remote shell command strings: a space split one path into two arguments and a backtick executed on the git server. Everything now goes through shq(), and chained remote commands use && so a failed cd cannot let the next command run in the login directory. - Bare `cd` panicked with an index-out-of-range and took down the shell; it now deselects the project. - Command-line mode set PRJ to the whole path below BASE, so `mgsh push` from a subdirectory staged only that subtree and addressed a bogus server path. It now truncates at the first path element. - `list` hardcoded owner and group "git git" in its regex and silently printed nothing on any server where the repositories are owned by someone else. - The config parser kept inline "#" comments in values although the README and the example file document them, so `mirror = true # ...` silently disabled mirroring. - ~/.mgshrc holds an API token but was created world-readable. - The mirror token was passed on git's command line, visible in the process table; it now goes through GIT_CONFIG_*. - tag, count and dist ran without a repository and operated on BASE. - checkout dropped its git options, because the dispatcher strips -x flags from the word list. - REPO was read with a plain `git config`, inheriting a foreign origin from an enclosing repository; it is now local-only and, being dead state otherwise, no longer recomputed on every prompt. - getkey consumed a single byte, leaving the rest of a typed answer in the tty queue where readline ran it as a command. - The REPL spun on any readline error that was neither EOF nor interrupt. - Tab completion cached an empty repository list after one failed ssh. - Startup did a blocking DNS lookup and three `git config --global` writes on every invocation. New: - A project may carry its own .mgshrc, overriding the global settings while it is active. Resolution order is ~/.mgshrc -> <project>/.mgshrc -> MGSH_*; base and the git identity keys stay global. It is read when the project changes, and `rescan` reloads it. - pushremote mirrors to any number of servers, configured as remote.<name>.url/key/type/visibility blocks. `pushremote` pushes to all of them, `pushremote @name ...` to a selection, and `remotes = ...` restricts and orders the set. Each target owns a git remote of the same name; a failing target no longer stops the others. - `config` shows the resolved configuration, its sources and the mirror targets with masked tokens; `config -k` lists the setting names. - gitkey was parsed and documented but never used. It is now the ssh identity for the git server, for mgsh's own ssh calls and, via GIT_SSH_COMMAND, for the git commands mgsh runs. - config, count, login and cloneall work from the command line too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
394 lines
11 KiB
Go
394 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) — either a
|
|
// single flat target:
|
|
//
|
|
// remoteurl = https://git.example.com base URL of the server
|
|
// remotekey = <api-token> personal access token
|
|
// remotetype = gitea|github|gitlab optional; auto-detected from the URL
|
|
//
|
|
// or any number of named ones, which `pushremote` mirrors to in turn:
|
|
//
|
|
// remote.gitea.url = https://git.example.com
|
|
// remote.gitea.key = <api-token>
|
|
// remote.hub.url = https://github.com
|
|
// remote.hub.key = <api-token>
|
|
// remote.hub.visibility = public
|
|
// remotes = gitea, hub optional: restrict/order the set
|
|
//
|
|
// Each target owns a git remote of the same name in the repository.
|
|
//
|
|
// 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")
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
// 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 'remoteurl'/'remotekey' or a 'remote.<name>.*' block 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)
|
|
if _, err := gitCapture(DIR, "remote", "get-url", t.Name); err == nil {
|
|
gitOK(DIR, "remote", "set-url", t.Name, web)
|
|
} else {
|
|
gitOK(DIR, "remote", "add", t.Name, web)
|
|
}
|
|
|
|
// authenticate the push with a one-shot Basic auth header, so the token is
|
|
// neither persisted in the repository's git config nor visible in `ps`
|
|
header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(owner+":"+api.key))
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|