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.. block per server, which `pushremote` mirrors to in turn. // // remote.gitlab.url = https://gitlab.example.com // remote.gitlab.key = // 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..url' and 'remote..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 ` 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 }