Files
mgsh/config.go
T
mikeandClaude Opus 5 2a622046f2 Define mirror targets one way: remote.<name>.<field>
There were two spellings for the same thing -- a flat
remoteurl/remotekey/remotetype/remotevisibility set for a single server,
and remote.<name>.* blocks for several. The flat one is gone; every
target, including a lone one, is now a named block with the fields url,
key, type and visibility.

An existing ~/.mgshrc is converted on the next start. Only the key is
rewritten, so values, comments, alignment, commented-out lines and the
file's 0600 mode survive untouched, and mgsh prints each rename rather
than doing it quietly. The target is named "public", which is what the
old settings called the git remote they created, so a converted setup
keeps pushing to the same place under the same remote name. A file that
carries both spellings keeps what the new one says.

The environment follows the same shape: MGSH_REMOTEURL and friends are
replaced by MGSH_REMOTE_<NAME>_<FIELD>, so MGSH_REMOTE_GITLAB_KEY sets
remote.gitlab.key. The field is read from the end of the variable name,
which leaves target names free to contain underscores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:00:26 +02:00

561 lines
18 KiB
Go

package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"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)
Mirror string // truthy -> `push` also mirrors via `pushremote`
SecretScan string // falsy -> `push` skips the credential scan
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 the pre-4.1 flat remoteurl/remotekey settings
// are migrated to. It matches the git remote those versions created, so a
// converted configuration keeps pushing to the same place.
const legacyRemoteName = "public"
// legacyRemoteKeys maps the old flat spelling onto the named-target form. A
// mirror target is defined one way now, not two.
var legacyRemoteKeys = map[string]string{
"remoteurl": "remote." + legacyRemoteName + ".url",
"remotekey": "remote." + legacyRemoteName + ".key",
"remotetype": "remote." + legacyRemoteName + ".type",
"remotevisibility": "remote." + legacyRemoteName + ".visibility",
}
// 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) {
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)
migrateRemoteKeys(path, string(data))
}
applyEnv(&c)
return c
}
// migrateRemoteKeys converts the pre-4.1 flat remote settings in a config file
// to the remote.<name>.<field> spelling, so a mirror target is defined one way
// and not two. Only the key is rewritten: values, comments, blank lines and the
// file's permissions stay exactly as they are, and commented-out lines are left
// alone. Reports what it changed rather than doing it silently.
func migrateRemoteKeys(path, data string) {
lines := strings.Split(data, "\n")
var renamed []string
for i, ln := range lines {
trimmed := strings.TrimLeft(ln, " \t")
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
sep := strings.IndexAny(trimmed, "=:")
if sep < 0 {
continue
}
key := strings.TrimRight(trimmed[:sep], " \t")
dotted, ok := legacyRemoteKeys[strings.ToLower(key)]
if !ok {
continue
}
indent := ln[:len(ln)-len(trimmed)]
gap := trimmed[len(key):sep] // whatever alignment was there
lines[i] = indent + dotted + gap + trimmed[sep:]
renamed = append(renamed, key+" → "+dotted)
}
if len(renamed) == 0 {
return
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), configMode); err != nil {
errorln("could not update " + path + ": " + err.Error())
return
}
fmt.Println(col(cGray, path+": mirror settings renamed to the remote.<name>.* form"))
for _, r := range renamed {
fmt.Println(col(cGray, " "+r))
}
}
// 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 'remote.<name>.*' block per server. `pushremote` pushes to all\n")
b.WriteString("# of them, `pushremote @gitlab` to a single one. <name> is also the\n")
b.WriteString("# git remote created in the repository.\n")
b.WriteString("# remote.gitlab.url = https://gitlab.example.com\n")
b.WriteString("# remote.gitlab.key = <personal-access-token>\n")
b.WriteString("# remote.gitlab.type = gitlab # optional; detected from the url\n")
b.WriteString("# remote.gitlab.visibility = private # or public (default private)\n")
b.WriteString("# remotes = gitlab # 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("remotes", &c.RemoteNames)
set("mirror", &c.Mirror)
set("secretscan", &c.SecretScan)
applyRemoteTargets(c, foldLegacyRemoteKeys(m))
}
// foldLegacyRemoteKeys rewrites the pre-4.1 flat remote settings into the
// named-target form, so a configuration that has not been converted yet still
// works while it is being read. The file itself is converted by
// migrateRemoteKeys; this only makes the current run behave.
func foldLegacyRemoteKeys(m map[string]string) map[string]string {
folded, copied := m, false
for old, dotted := range legacyRemoteKeys {
v, ok := m[old]
if !ok || v == "" {
continue
}
if _, taken := m[dotted]; taken {
continue // an explicit new-style setting always wins
}
if !copied { // copy on first write, never touch the caller's map
folded = make(map[string]string, len(m))
for k, val := range m {
folded[k] = val
}
copied = true
}
folded[dotted] = v
}
return folded
}
// 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_REMOTES", &c.RemoteNames)
env("MGSH_MIRROR", &c.Mirror)
env("MGSH_SECRETSCAN", &c.SecretScan)
applyRemoteEnv(c)
}
// remoteFields are the settings a mirror target is made of.
var remoteFields = []string{"url", "key", "type", "visibility"}
// applyRemoteEnv reads MGSH_REMOTE_<NAME>_<FIELD>, the environment spelling of
// a remote.<name>.<field> setting — MGSH_REMOTE_GITLAB_KEY for
// remote.gitlab.key. The field is taken from the end, so a target name may
// contain underscores itself.
func applyRemoteEnv(c *Config) {
const prefix = "MGSH_REMOTE_"
// sorted, so a target these variables introduce lands in the push order the
// same way on every run
envs := os.Environ()
sort.Strings(envs)
for _, kv := range envs {
eq := strings.IndexByte(kv, '=')
if eq < 0 {
continue
}
name, value := kv[:eq], kv[eq+1:]
if value == "" || !strings.HasPrefix(name, prefix) {
continue
}
rest := name[len(prefix):]
us := strings.LastIndexByte(rest, '_')
if us <= 0 {
continue
}
target, field := strings.ToLower(rest[:us]), strings.ToLower(rest[us+1:])
if !slices.Contains(remoteFields, field) {
continue // MGSH_REMOTES and anything else that merely starts alike
}
t := &c.Remotes[c.remoteIndex(target)]
switch field {
case "url":
t.URL = value
case "key":
t.Key = value
case "type":
t.Type = value
case "visibility":
t.Vis = value
}
}
}