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:
2026-07-26 11:37:49 +02:00
co-authored by Claude Opus 5
parent 5562055695
commit ae8c7a3ec0
11 changed files with 1480 additions and 203 deletions
+127 -38
View File
@@ -28,15 +28,16 @@ const INFO = "mwx'2026"
// Global runtime state.
var (
cfg Config // resolved configuration
BASE string // project base directory (cfg.Base)
baseCfg Config // global configuration: ~/.mgshrc + MGSH_*
cfg Config // effective configuration: baseCfg + the project's .mgshrc
cfgPRJ string // project whose .mgshrc is currently applied
cfgFresh bool // whether cfg has been resolved at least once
BASE string // project base directory (baseCfg.Base)
URL string // ssh:// clone/push URL
PRJ string // current project (may be empty)
DIR string // current working directory (BASE or BASE/PRJ)
IP string // local IP, embedded into the initial commit message
HOST string // short hostname
USER string // current user name
REPO string // repository name parsed from remote.origin.url
BPLSTATE string // "/OFF" if build.pl symlink points at a *.off file
BRANCH string // current git branch of the active project (if any)
DIRTY bool // whether the active project has uncommitted changes
@@ -50,7 +51,7 @@ func main() {
fmt.Println(banner())
// preset project from the first argument (interactive launch: `mgsh myproj`)
if len(os.Args) > 1 && isDir(BASE+"/"+os.Args[1]) {
if len(os.Args) > 1 && validProject(os.Args[1]) && isDir(BASE+"/"+os.Args[1]) {
PRJ = os.Args[1]
}
@@ -61,11 +62,7 @@ func main() {
return
}
if clCmd != 0 {
pwd, _ := os.Getwd()
PRJ = ""
if strings.HasPrefix(pwd, BASE+"/") {
PRJ = pwd[len(BASE)+1:]
}
PRJ = projectFromCwd()
if clCmd == 2 {
cmdline = strings.TrimSpace(cmdline + " " + PRJ)
}
@@ -106,6 +103,9 @@ func runInteractive() {
continue
} else if err == io.EOF { // Ctrl-D
break
} else if err != nil { // detached/broken terminal: don't spin
fmt.Fprintln(os.Stderr, err)
break
}
if !runCommand(strings.TrimSpace(line)) {
break
@@ -113,6 +113,25 @@ func runInteractive() {
}
}
// projectFromCwd derives the active project from the working directory: the
// first path element below BASE, so running `mgsh push` from deep inside a
// project still addresses the project itself and not the subdirectory.
// Returns "" when the working directory is outside BASE.
func projectFromCwd() string {
pwd, err := os.Getwd()
if err != nil || !strings.HasPrefix(pwd, BASE+"/") {
return ""
}
rel := pwd[len(BASE)+1:]
if i := strings.IndexByte(rel, '/'); i >= 0 {
rel = rel[:i]
}
if !validProject(rel) {
return ""
}
return rel
}
// parseArgs mirrors the original command-line dispatch table. It returns the
// "CLCMD" class (0 = interactive, 1 = plain, 2 = append project name), the raw
// command line, and whether `help` was requested.
@@ -128,6 +147,7 @@ func parseArgs() (int, string, bool) {
"clone": 2, "init": 2, "log": 2,
"push": 1, "pushremote": 1, "list": 1, "tag": 1, "archive": 1, "show": 1, "open": 1,
"pull": 1, "fetch": 1, "status": 1, "diff": 1, "overview": 1,
"config": 1, "count": 1, "login": 1, "cloneall": 1,
}
if c, ok := cls[a0]; ok {
return c, strings.Join(os.Args[1:], " "), false
@@ -136,45 +156,108 @@ func parseArgs() (int, string, bool) {
}
func setup() {
cfg = loadConfig()
if miss := cfg.missingRequired(); len(miss) > 0 {
// the *global* configuration has to stand on its own: mgsh must be usable
// outside any project, where no project .mgshrc can fill in the blanks.
baseCfg = loadConfig()
if miss := baseCfg.missingRequired(); len(miss) > 0 {
fmt.Fprintf(os.Stderr, "mgsh: not configured — missing required settings: %s\n", strings.Join(miss, ", "))
fmt.Fprintf(os.Stderr, "edit %s or set the corresponding MGSH_* environment variables, then run mgsh again.\n", configFile())
os.Exit(1)
}
BASE = cfg.Base
BASE = baseCfg.Base
if !isDir(BASE) {
fmt.Fprintf(os.Stderr, "%s not found\n", BASE)
os.Exit(1)
}
URL = fmt.Sprintf("ssh://%s@%s:%s%s", cfg.GitUser, cfg.GitHost, cfg.GitPort, cfg.GitPath)
IP = resolveIP()
applyProjectConfig() // no project yet: cfg = baseCfg, URL from it
HOST = shortHostname()
if u, err := user.Current(); err == nil {
USER = u.Username
}
// git identity / settings, from config (defaults preserve prior behavior)
if cfg.GitName != "" {
git("", "config", "--global", "user.name", cfg.GitName)
}
if cfg.GitEmail != "" {
git("", "config", "--global", "user.email", cfg.GitEmail)
}
if cfg.PushDefault != "" {
git("", "config", "--global", "push.default", cfg.PushDefault)
}
applyGlobalGitConfig()
loadAliases()
}
// updateDirState recomputes DIR, the build.pl OFF marker, the current branch /
// dirty flag and the parsed repository name for the active project. Run once
// per loop iteration.
// applyGlobalGitConfig writes the configured git identity to ~/.gitconfig, but
// only the values that actually differ — every mgsh invocation runs this, and a
// plain `mgsh status` has no business rewriting the user's global config. The
// current settings are read in one go rather than one process per key.
func applyGlobalGitConfig() {
want := map[string]string{}
if baseCfg.GitName != "" {
want["user.name"] = baseCfg.GitName
}
if baseCfg.GitEmail != "" {
want["user.email"] = baseCfg.GitEmail
}
if baseCfg.PushDefault != "" {
want["push.default"] = baseCfg.PushDefault
}
if len(want) == 0 {
return
}
have := map[string]string{}
if out, err := gitCapture("", "config", "--global", "--list"); err == nil {
for _, ln := range splitLines(out) {
if i := strings.IndexByte(ln, '='); i > 0 {
have[ln[:i]] = ln[i+1:]
}
}
}
for k, v := range want {
if have[k] != v {
git("", "config", "--global", k, v)
}
}
}
// applyProjectConfig re-resolves cfg whenever the active project changes: the
// project's own .mgshrc overrides the global settings, so a single project can
// live on a different server or mirror to a different place. Resolving once per
// project change rather than once per prompt keeps the REPL loop free of I/O.
func applyProjectConfig() {
if cfgFresh && PRJ == cfgPRJ {
return
}
cfgFresh, cfgPRJ = true, PRJ
dir := ""
if PRJ != "" && isDir(BASE+"/"+PRJ) {
dir = BASE + "/" + PRJ
}
cfg = resolveConfig(baseCfg, dir)
URL = fmt.Sprintf("ssh://%s@%s:%s%s", cfg.GitUser, cfg.GitHost, cfg.GitPort, cfg.GitPath)
}
// reloadConfig re-reads ~/.mgshrc and the project's .mgshrc, so an edit takes
// effect without restarting the shell. An edit that breaks the global config is
// rejected rather than applied — the running shell keeps working.
func reloadConfig() {
fresh := loadConfig()
if miss := fresh.missingRequired(); len(miss) > 0 {
errorln("keeping the previous configuration — " + configFile() +
" is missing: " + strings.Join(miss, ", "))
return
}
if fresh.Base != BASE {
errorln("'base' changed to " + fresh.Base + " — restart mgsh to use it")
fresh.Base = BASE
}
baseCfg = fresh
cfgFresh = false
applyProjectConfig()
}
// updateDirState recomputes DIR, the build.pl OFF marker and the current
// branch / dirty flag for the active project. Run once per loop iteration, so
// it stays deliberately cheap.
func updateDirState() {
BRANCH = ""
DIRTY = false
applyProjectConfig()
if PRJ != "" && isDir(BASE+"/"+PRJ) {
DIR = BASE + "/" + PRJ
@@ -197,13 +280,19 @@ func updateDirState() {
DIR = BASE
BPLSTATE = ""
}
// derive REPO from the origin URL: basename without the ".git" suffix.
REPO = ""
if out, err := gitCapture(DIR, "config", "remote.origin.url"); err == nil {
remurl := strings.TrimRight(strings.TrimSpace(out), "/")
if remurl != "" {
REPO = strings.TrimSuffix(filepath.Base(remurl), ".git")
}
}
}
// originURL returns the active project's own origin URL, or "" when it is not a
// repository or has no origin. `--local` matters: a plain `git config` walks up
// into an enclosing repository and would report a foreign origin for a project
// that has no repository of its own.
func originURL() string {
if !isDir(DIR + "/.git") {
return ""
}
out, err := gitCapture(DIR, "config", "--local", "remote.origin.url")
if err != nil {
return ""
}
return strings.TrimSpace(out)
}