Files
mgsh/config.go
T
2026-07-28 15:25:54 +02:00

558 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)
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(cDark, path+": mirror settings renamed to the remote.<name>.* form"))
for _, r := range renamed {
fmt.Println(col(cDark, " "+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("\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("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_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
}
}
}