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
+280 -11
View File
@@ -2,22 +2,33 @@ package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Config holds all externally configurable settings. It is read entirely from
// ~/.mgshrc and then overlaid with MGSH_* environment variables — there are no
// built-in defaults. Missing required settings are a fatal error (see
// missingRequired).
// configMode is the permission mask for ~/.mgshrc. The file holds the
// 'remotekey' API token, so it must not be readable by other local users.
const configMode = 0o600
// projectRC is the per-project configuration file, read from the active
// project directory and overlaid on the global settings.
const projectRC = ".mgshrc"
// Config holds all externally configurable settings. It is read from ~/.mgshrc,
// overlaid with the active project's own .mgshrc and then with MGSH_*
// environment variables — there are no built-in defaults. Missing required
// settings are a fatal error (see missingRequired).
type Config struct {
Base string // project base directory
Base string // project base directory (global only)
GitHost string // git server host
GitPort string // ssh port
GitUser string // ssh user
GitPath string // remote path holding the bare repos
GitKey string // ssh key name (informational)
GitKey string // ssh identity for the git server ("" = ssh defaults)
GitName string // git user.name to set globally ("" = leave alone)
GitEmail string // git user.email to set globally ("" = leave alone)
PushDefault string // git push.default to set globally ("" = leave alone)
@@ -27,6 +38,74 @@ type Config struct {
RemoteType string // "gitea"|"github"|"gitlab" (auto-detected when empty)
RemoteVis string // visibility of created repos: "private" (default)|"public"
Mirror string // truthy -> `push` also mirrors via `pushremote`
Remotes []RemoteTarget
RemoteNames string // "remotes": explicit, ordered subset of targets to use
}
// RemoteTarget is one public mirror server for `pushremote`, configured as a
// `remote.<name>.<field>` block. Name doubles as the git remote name created in
// the repository, so several targets can coexist side by side.
type RemoteTarget struct {
Name string
URL string
Key string
Type string // "gitea"|"github"|"gitlab" (auto-detected when empty)
Vis string // "private" (default) | "public"
}
// legacyRemoteName is the target name for the flat remoteurl/remotekey pair,
// matching the git remote that earlier versions created.
const legacyRemoteName = "public"
// mirrorTargets returns the usable mirror targets in configured order, plus the
// names of targets that are defined but unusable (missing url or key) so the
// caller can complain about them instead of silently skipping.
func (c Config) mirrorTargets() (usable []RemoteTarget, incomplete []string) {
var all []RemoteTarget
if c.RemoteURL != "" || c.RemoteKey != "" {
all = append(all, RemoteTarget{
Name: legacyRemoteName, URL: c.RemoteURL, Key: c.RemoteKey,
Type: c.RemoteType, Vis: c.RemoteVis,
})
}
all = append(all, c.Remotes...)
// `remotes = a, b` narrows and orders the set — a project .mgshrc uses it
// to mirror to only some of the globally configured servers.
if sel := splitList(c.RemoteNames); len(sel) > 0 {
var picked []RemoteTarget
for _, n := range sel {
for _, t := range all {
if strings.EqualFold(t.Name, n) {
picked = append(picked, t)
break
}
}
}
all = picked
}
for _, t := range all {
if t.URL == "" || t.Key == "" {
incomplete = append(incomplete, t.Name)
continue
}
usable = append(usable, t)
}
return usable, incomplete
}
// splitList splits a comma- or whitespace-separated setting into its items.
func splitList(s string) []string {
var out []string
for _, f := range strings.FieldsFunc(s, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t'
}) {
if f != "" {
out = append(out, f)
}
}
return out
}
// requiredKeys lists the settings mgsh cannot run without.
@@ -64,12 +143,120 @@ func loadConfig() Config {
}
var c Config
if data, err := os.ReadFile(path); err == nil {
applyConfig(&c, parseConfig(string(data)))
m := parseConfig(string(data))
applyConfig(&c, m)
warnConfigPerms(path, m)
}
applyEnv(&c)
return c
}
// projectGlobalOnly lists settings a project-level .mgshrc must not change:
// `base` decides where projects live in the first place, and the git identity
// keys are written to the user's *global* git config at startup — applying
// those per project would rewrite ~/.gitconfig on every `cd`.
var projectGlobalOnly = []string{"base", "gitname", "gitemail", "pushdefault"}
// resolveConfig returns the effective configuration for a project directory:
// the global settings, overlaid with the project's own .mgshrc, with MGSH_*
// applied last so an explicit environment override still wins. dir may be ""
// (no project active), which yields the global configuration unchanged.
func resolveConfig(base Config, dir string) Config {
c := base
// Remotes is a slice: copy it, or a project overlay would write through the
// shared backing array into the global configuration.
c.Remotes = append([]RemoteTarget(nil), base.Remotes...)
if dir == "" {
return c
}
path := filepath.Join(dir, projectRC)
data, err := os.ReadFile(path)
if err != nil {
return c
}
m := parseConfig(string(data))
var ignored []string
for _, k := range projectGlobalOnly {
if _, ok := m[k]; ok {
ignored = append(ignored, k)
delete(m, k)
}
}
if len(ignored) > 0 {
errorln(fmt.Sprintf("%s: ignoring global-only settings: %s",
path, strings.Join(ignored, ", ")))
}
applyConfig(&c, m)
applyEnv(&c)
warnConfigPerms(path, m)
warnConfigTracked(dir, m)
return c
}
// hasSecret reports whether a parsed config assigns an API token.
func hasSecret(m map[string]string) bool {
if m["remotekey"] != "" {
return true
}
for k, v := range m {
if v != "" && strings.HasSuffix(k, ".key") && remoteFieldRe.MatchString(k) {
return true
}
}
return false
}
// warnConfigPerms complains when a config file holding an API token is readable
// by anyone else. New files are created 0600, but a file written by an earlier
// version — or by hand — is not silently re-chmodded behind the user's back.
func warnConfigPerms(path string, m map[string]string) {
if !hasSecret(m) {
return
}
fi, err := os.Stat(path)
if err != nil || fi.Mode().Perm()&0o077 == 0 {
return
}
errorln(fmt.Sprintf("warning: %s holds an API token but is mode %04o — run: chmod 600 %s",
path, fi.Mode().Perm(), path))
}
// warnConfigTracked complains when a project .mgshrc holds an API token and git
// would happily commit it. A project config is a normal file in the working
// tree and `push` commits everything, so this is an easy way to publish a token
// by accident — including on the very first commit made by `init`, which is why
// a project without a repository yet is checked too.
func warnConfigTracked(dir string, m map[string]string) {
if !hasSecret(m) || projectRCIgnored(dir) {
return
}
errorln("warning: " + filepath.Join(dir, projectRC) +
" holds an API token and is not git-ignored — add it to .gitignore")
}
// projectRCIgnored reports whether git would leave the project config out of a
// commit. In a repository git itself answers; before `init` there is no
// repository yet, so the .gitignore that init would use is read directly.
func projectRCIgnored(dir string) bool {
if isDir(dir + "/.git") {
return runQuiet(dir, "git", "check-ignore", "-q", projectRC) == nil
}
data, err := os.ReadFile(filepath.Join(dir, ".gitignore"))
if err != nil {
return false
}
for _, ln := range strings.Split(string(data), "\n") {
switch strings.TrimSpace(ln) {
case projectRC, "/" + projectRC:
return true
}
}
return false
}
// writeConfigTemplate creates a blank, annotated ~/.mgshrc for the user to fill
// in, migrating any aliases from the legacy ~/.mgsh_aliases file. It writes no
// real values — mgsh has no built-in configuration.
@@ -89,14 +276,24 @@ func writeConfigTemplate(path string) {
b.WriteString("# gitname = Your Name\n")
b.WriteString("# gitemail = you@example.com\n")
b.WriteString("# pushdefault = matching\n")
b.WriteString("# editor = code\n")
b.WriteString("# editor = code\n\n")
b.WriteString("# --- public mirrors for `pushremote` ---\n")
b.WriteString("# One block per server; `pushremote` pushes to all of them,\n")
b.WriteString("# `pushremote @hub` to a single one.\n")
b.WriteString("# remote.hub.url = https://github.com\n")
b.WriteString("# remote.hub.key = <personal-access-token>\n")
b.WriteString("# remote.hub.visibility = public\n")
b.WriteString("# remotes = hub # optional: restrict/order the set\n")
b.WriteString("# mirror = true # `push` also mirrors\n\n")
b.WriteString("# A project may override any of these (except base and the git\n")
b.WriteString("# identity) in its own <project>/.mgshrc.\n")
legacy := readLegacyAliases()
if blk := aliasBlock(legacy); blk != "" {
b.WriteString("\n" + blk)
}
if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil {
if err := os.WriteFile(path, []byte(b.String()), configMode); err != nil {
errorln("could not create " + path + ": " + err.Error())
return
}
@@ -124,12 +321,33 @@ func parseConfig(s string) map[string]string {
continue
}
key := strings.ToLower(strings.TrimSpace(line[:i]))
val := strings.Trim(strings.TrimSpace(line[i+1:]), "\"'")
m[key] = val
val := stripInlineComment(strings.TrimSpace(line[i+1:]))
m[key] = strings.Trim(val, "\"'")
}
return m
}
// stripInlineComment removes a trailing `#` comment from a config value, as
// documented in the README and the generated template. The '#' must follow
// whitespace, so a value may still contain a literal '#' (an API token, a URL
// fragment). A quoted value is taken verbatim up to its closing quote.
func stripInlineComment(v string) string {
if strings.HasPrefix(v, "#") {
return ""
}
if len(v) > 1 && (v[0] == '"' || v[0] == '\'') {
if j := strings.IndexByte(v[1:], v[0]); j >= 0 {
return v[:j+2]
}
}
for i := 1; i < len(v); i++ {
if v[i] == '#' && (v[i-1] == ' ' || v[i-1] == '\t') {
return strings.TrimRight(v[:i], " \t")
}
}
return v
}
func applyConfig(c *Config, m map[string]string) {
set := func(key string, dst *string) {
if v, ok := m[key]; ok && v != "" {
@@ -150,7 +368,57 @@ func applyConfig(c *Config, m map[string]string) {
set("remotekey", &c.RemoteKey)
set("remotetype", &c.RemoteType)
set("remotevisibility", &c.RemoteVis)
set("remotes", &c.RemoteNames)
set("mirror", &c.Mirror)
applyRemoteTargets(c, m)
}
// remoteFieldRe matches a named mirror target setting: remote.<name>.<field>.
var remoteFieldRe = regexp.MustCompile(`^remote\.([a-z0-9_.-]+)\.(url|key|type|visibility)$`)
// applyRemoteTargets merges `remote.<name>.<field>` settings into c.Remotes.
// An already known target is updated field by field, so a project .mgshrc can
// override just the visibility of a globally configured server. New targets are
// appended in key order, which keeps the push order deterministic.
func applyRemoteTargets(c *Config, m map[string]string) {
keys := make([]string, 0, len(m))
for k := range m {
if remoteFieldRe.MatchString(k) {
keys = append(keys, k)
}
}
sort.Strings(keys)
for _, k := range keys {
v := m[k]
if v == "" {
continue
}
f := remoteFieldRe.FindStringSubmatch(k)
t := &c.Remotes[c.remoteIndex(f[1])]
switch f[2] {
case "url":
t.URL = v
case "key":
t.Key = v
case "type":
t.Type = v
case "visibility":
t.Vis = v
}
}
}
// remoteIndex returns the position of the named target, appending an empty one
// when it is not there yet.
func (c *Config) remoteIndex(name string) int {
for i := range c.Remotes {
if c.Remotes[i].Name == name {
return i
}
}
c.Remotes = append(c.Remotes, RemoteTarget{Name: name})
return len(c.Remotes) - 1
}
func applyEnv(c *Config) {
@@ -173,5 +441,6 @@ func applyEnv(c *Config) {
env("MGSH_REMOTEKEY", &c.RemoteKey)
env("MGSH_REMOTETYPE", &c.RemoteType)
env("MGSH_REMOTEVISIBILITY", &c.RemoteVis)
env("MGSH_REMOTES", &c.RemoteNames)
env("MGSH_MIRROR", &c.Mirror)
}