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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user