Files
mgsh/config.go
T
mikeandClaude Opus 5 ae8c7a3ec0 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>
2026-07-26 11:37:49 +02:00

447 lines
14 KiB
Go

package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// 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 (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 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)
Editor string // editor/opener used as fallback by `open` ("" = coda)
RemoteURL string // public mirror server base URL (Gitea/GitHub/GitLab)
RemoteKey string // API token for the mirror server (used by `pushremote`)
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.
var requiredKeys = []string{"base", "githost", "gitport", "gituser", "gitpath"}
// missingRequired reports which required settings are still unset. mgsh refuses
// to start while this is non-empty.
func (c Config) missingRequired() []string {
have := map[string]string{
"base": c.Base, "githost": c.GitHost, "gitport": c.GitPort,
"gituser": c.GitUser, "gitpath": c.GitPath,
}
var missing []string
for _, k := range requiredKeys {
if have[k] == "" {
missing = append(missing, k)
}
}
return missing
}
// configFile returns the path of the user config file (~/.mgshrc).
func configFile() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".mgshrc")
}
// loadConfig writes an empty ~/.mgshrc template on first run, then returns the
// configuration parsed from it, overlaid with MGSH_* environment variables.
// It applies no defaults; validation of required settings is the caller's job.
func loadConfig() Config {
path := configFile()
if !fileExists(path) {
writeConfigTemplate(path)
}
var c Config
if data, err := os.ReadFile(path); err == nil {
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.
func writeConfigTemplate(path string) {
var b strings.Builder
b.WriteString("# mgsh configuration — fill in the required settings, then run mgsh again.\n")
b.WriteString("# Format: 'key = value' (or 'key: value'); '#' starts a comment.\n")
b.WriteString("# MGSH_* environment variables override these settings.\n\n")
b.WriteString("# --- required ---\n")
b.WriteString("base =\n")
b.WriteString("githost =\n")
b.WriteString("gitport =\n")
b.WriteString("gituser =\n")
b.WriteString("gitpath =\n\n")
b.WriteString("# --- optional ---\n")
b.WriteString("# gitkey =\n")
b.WriteString("# gitname = Your Name\n")
b.WriteString("# gitemail = you@example.com\n")
b.WriteString("# pushdefault = matching\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()), configMode); err != nil {
errorln("could not create " + path + ": " + err.Error())
return
}
if len(legacy) > 0 {
os.Remove(legacyAliasFile()) // aliases now live in ~/.mgshrc
}
}
// parseConfig reads simple `key = value` (or `key: value`) lines, ignoring
// blank lines, `#` comments and `alias` definitions. Keys are lower-cased;
// values are unquoted.
func parseConfig(s string) map[string]string {
m := map[string]string{}
sc := bufio.NewScanner(strings.NewReader(s))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if f := strings.Fields(line); len(f) > 0 && f[0] == "alias" {
continue // alias definitions are handled by the alias loader
}
i := strings.IndexAny(line, "=:")
if i < 0 {
continue
}
key := strings.ToLower(strings.TrimSpace(line[:i]))
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 != "" {
*dst = v
}
}
set("base", &c.Base)
set("githost", &c.GitHost)
set("gitport", &c.GitPort)
set("gituser", &c.GitUser)
set("gitpath", &c.GitPath)
set("gitkey", &c.GitKey)
set("gitname", &c.GitName)
set("gitemail", &c.GitEmail)
set("pushdefault", &c.PushDefault)
set("editor", &c.Editor)
set("remoteurl", &c.RemoteURL)
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) {
env := func(key string, dst *string) {
if v := os.Getenv(key); v != "" {
*dst = v
}
}
env("MGSH_BASE", &c.Base)
env("MGSH_GITHOST", &c.GitHost)
env("MGSH_GITPORT", &c.GitPort)
env("MGSH_GITUSER", &c.GitUser)
env("MGSH_GITPATH", &c.GitPath)
env("MGSH_GITKEY", &c.GitKey)
env("MGSH_GITNAME", &c.GitName)
env("MGSH_GITEMAIL", &c.GitEmail)
env("MGSH_PUSHDEFAULT", &c.PushDefault)
env("MGSH_EDITOR", &c.Editor)
env("MGSH_REMOTEURL", &c.RemoteURL)
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)
}