Files
mgsh/show_config.go
T
mikeandClaude Opus 5 ae8c7a3ec0 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>
2026-07-26 11:37:49 +02:00

155 lines
4.4 KiB
Go

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
}