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