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 `view` ("" = 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..` 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.. 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..* 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..*' block per server. `pushremote` pushes to all\n") b.WriteString("# of them, `pushremote @gitlab` to a single one. 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 = \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 /.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... var remoteFieldRe = regexp.MustCompile(`^remote\.([a-z0-9_.-]+)\.(url|key|type|visibility)$`) // applyRemoteTargets merges `remote..` 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__, the environment spelling of // a remote.. 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 } } }