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:
+154
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
// show_config.go — the `config` command: print the configuration mgsh actually
|
||||
// resolved, and where it came from. With three layers (~/.mgshrc, the project's
|
||||
// .mgshrc, MGSH_*) and mirror targets that can be added or narrowed per
|
||||
// project, "what is in effect right now" is otherwise guesswork.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// configItem is one reported setting.
|
||||
type configItem struct{ key, value string }
|
||||
|
||||
// showConfig prints the effective configuration, marking every value that does
|
||||
// not come from the global file with its source.
|
||||
func showConfig() {
|
||||
global := configFile()
|
||||
project := ""
|
||||
if PRJ != "" && fileExists(DIR+"/"+projectRC) {
|
||||
project = DIR + "/" + projectRC
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", col(cGray, "global "), col(cCyan, global))
|
||||
if project != "" {
|
||||
fmt.Printf("%s %s\n", col(cGray, "project"), col(cCyan, project))
|
||||
} else if PRJ != "" {
|
||||
fmt.Printf("%s %s\n", col(cGray, "project"), col(cGray, "no "+projectRC+" in "+PRJ))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
items := []configItem{
|
||||
{"base", cfg.Base},
|
||||
{"githost", cfg.GitHost},
|
||||
{"gitport", cfg.GitPort},
|
||||
{"gituser", cfg.GitUser},
|
||||
{"gitpath", cfg.GitPath},
|
||||
{"gitkey", cfg.GitKey},
|
||||
{"gitname", cfg.GitName},
|
||||
{"gitemail", cfg.GitEmail},
|
||||
{"pushdefault", cfg.PushDefault},
|
||||
{"editor", cfg.Editor},
|
||||
{"mirror", cfg.Mirror},
|
||||
{"remotes", cfg.RemoteNames},
|
||||
}
|
||||
|
||||
// which keys the active project's file actually sets, for the source column
|
||||
fromProject := map[string]bool{}
|
||||
if project != "" {
|
||||
if data, err := os.ReadFile(project); err == nil {
|
||||
for k := range parseConfig(string(data)) {
|
||||
fromProject[k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, it := range items {
|
||||
if it.value == "" {
|
||||
continue
|
||||
}
|
||||
src := ""
|
||||
switch {
|
||||
case os.Getenv(envName(it.key)) != "":
|
||||
src = col(cYellow, " (env)")
|
||||
case fromProject[it.key]:
|
||||
src = col(cYellow, " ("+projectRC+")")
|
||||
}
|
||||
fmt.Printf(" %s%s%s\n", col(cGreen, padRight(it.key, 14)), it.value, src)
|
||||
}
|
||||
|
||||
if k := sshKeyPath(); k != "" {
|
||||
fmt.Printf(" %s%s\n", col(cGray, padRight("ssh identity", 14)), col(cGray, k))
|
||||
}
|
||||
fmt.Printf(" %s%s\n", col(cGray, padRight("clone url", 14)), col(cGray, URL))
|
||||
// the project's real origin: it can differ from what the current settings
|
||||
// would produce, e.g. after moving the server or editing a project .mgshrc
|
||||
if o := originURL(); o != "" {
|
||||
fmt.Printf(" %s%s\n", col(cGray, padRight("origin", 14)), col(cGray, o))
|
||||
}
|
||||
|
||||
showRemotes()
|
||||
}
|
||||
|
||||
// showRemotes lists the `pushremote` targets in push order, with the tokens
|
||||
// masked — `config` is the kind of output that ends up pasted into a bug report.
|
||||
func showRemotes() {
|
||||
targets, incomplete := cfg.mirrorTargets()
|
||||
fmt.Println()
|
||||
if len(targets) == 0 && len(incomplete) == 0 {
|
||||
fmt.Println(col(cGray, "no pushremote targets configured"))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println(col(cGray, "pushremote targets (in push order):"))
|
||||
for _, t := range targets {
|
||||
vis := "private"
|
||||
if strings.EqualFold(strings.TrimSpace(t.Vis), "public") {
|
||||
vis = "public"
|
||||
}
|
||||
kind := t.Type
|
||||
if kind == "" {
|
||||
kind = remoteKindName(detectRemoteKind(t.URL, "")) + " (detected)"
|
||||
}
|
||||
fmt.Printf(" %s%s %s\n",
|
||||
col(cGreen, padRight("@"+t.Name, 14)), t.URL,
|
||||
col(cGray, kind+", "+vis+", key "+maskSecret(t.Key)))
|
||||
}
|
||||
for _, n := range incomplete {
|
||||
fmt.Printf(" %s%s\n", col(cRed, padRight("@"+n, 14)), col(cRed, "incomplete: url or key missing"))
|
||||
}
|
||||
}
|
||||
|
||||
// remoteKindName renders a remoteKind for display.
|
||||
func remoteKindName(k remoteKind) string {
|
||||
switch k {
|
||||
case kindGitHub:
|
||||
return "github"
|
||||
case kindGitLab:
|
||||
return "gitlab"
|
||||
default:
|
||||
return "gitea"
|
||||
}
|
||||
}
|
||||
|
||||
// maskSecret reduces a token to a recognisable but useless stub.
|
||||
func maskSecret(s string) string {
|
||||
if s == "" {
|
||||
return "(unset)"
|
||||
}
|
||||
if len(s) <= 4 {
|
||||
return strings.Repeat("*", len(s))
|
||||
}
|
||||
return s[:2] + strings.Repeat("*", len(s)-4) + s[len(s)-2:]
|
||||
}
|
||||
|
||||
// envName maps a config key to its MGSH_* environment variable.
|
||||
func envName(key string) string { return "MGSH_" + strings.ToUpper(key) }
|
||||
|
||||
// configKeys lists every flat setting name — that is, every key that also has
|
||||
// an MGSH_* override. The `remote.<name>.*` targets are a pattern, not a fixed
|
||||
// set, and are reported separately.
|
||||
func configKeys() []string {
|
||||
keys := []string{
|
||||
"base", "githost", "gitport", "gituser", "gitpath", "gitkey",
|
||||
"gitname", "gitemail", "pushdefault", "editor",
|
||||
"remoteurl", "remotekey", "remotetype", "remotevisibility",
|
||||
"remotes", "mirror",
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
Reference in New Issue
Block a user