Fix review findings, add per-project config and multi-target pushremote

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>
This commit is contained in:
2026-07-26 11:37:49 +02:00
co-authored by Claude Opus 5
parent 5562055695
commit ae8c7a3ec0
11 changed files with 1480 additions and 203 deletions
+123 -35
View File
@@ -4,14 +4,28 @@ package main
// 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):
// 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.
// 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"
@@ -208,40 +222,105 @@ func (r *remoteAPI) repoWebURL(owner, repo string) string {
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) {
// 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
}
if cfg.RemoteURL == "" || cfg.RemoteKey == "" {
errorln("pushremote needs 'remoteurl' and 'remotekey' in ~/.mgshrc")
return
names, description := parsePushRemoteArgs(args)
targets, incomplete := cfg.mirrorTargets()
for _, n := range incomplete {
errorln("remote " + n + ": url or key missing — skipped")
}
repo := PRJ
if repo == "" {
repo = REPO
}
if repo == "" {
errorln("cannot determine repository name")
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
}
private := !strings.EqualFold(strings.TrimSpace(cfg.RemoteVis), "public")
repo := PRJ // requireRepo() guarantees a project, which names the repository
api := newRemoteAPI(cfg.RemoteURL, cfg.RemoteKey, cfg.RemoteType)
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(err.Error())
return
errorln(t.Name + ": " + err.Error())
return false
}
fmt.Printf("%s %s (as %s)\n", col(cGray, "remote"), col(cCyan, api.url), col(cGreen, owner))
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(err.Error())
return
errorln(t.Name + ": " + err.Error())
return false
}
if exists {
fmt.Printf("repository %s exists\n", col(cGreen, owner+"/"+repo))
@@ -252,39 +331,48 @@ func handlePushRemote(description string) {
}
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
errorln(t.Name + ": " + err.Error())
return false
}
}
// keep a credential-free remote named "public" for convenience
// keep a credential-free git remote named after the target
web := api.repoWebURL(owner, repo)
if _, err := gitCapture(DIR, "remote", "get-url", "public"); err == nil {
gitOK(DIR, "remote", "set-url", "public", web)
if _, err := gitCapture(DIR, "remote", "get-url", t.Name); err == nil {
gitOK(DIR, "remote", "set-url", t.Name, web)
} else {
gitOK(DIR, "remote", "add", "public", web)
gitOK(DIR, "remote", "add", t.Name, web)
}
// authenticate the push with a one-shot Basic auth header so the token is
// never persisted in the repository's git config
// 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, "public", header, "--all") {
return
if !gitPushHeader(DIR, t.Name, header, "--all") {
return false
}
gitPushHeader(DIR, "public", header, "--tags")
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 {
full := append([]string{"-c", "http.extraHeader=" + header, "push", remote}, args...)
c := exec.Command("git", full...)
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")
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