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:
@@ -5,6 +5,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/chzyer/readline"
|
||||
)
|
||||
|
||||
// yesno asks a y/n question with a default. Reads a single keypress.
|
||||
@@ -17,18 +19,28 @@ func yesno(prompt string, def bool) bool {
|
||||
if ans == "" {
|
||||
return def
|
||||
}
|
||||
return ans == "y" || ans == "yes"
|
||||
return ans == "y"
|
||||
}
|
||||
|
||||
// getkey reads a single keypress from the terminal without echo. It reads a
|
||||
// single byte directly from stdin; readline is not reading at this point (we
|
||||
// are inside command execution), so there is no reader to desync with.
|
||||
//
|
||||
// Anything else already typed on the same line is discarded: answering "yes"
|
||||
// to a y/n prompt must not leave "es\n" queued for the next readline call,
|
||||
// where it would come back as a bogus command.
|
||||
func getkey(prompt string) string {
|
||||
fmt.Print(prompt)
|
||||
stty("-icanon", "-echo")
|
||||
tty := readline.IsTerminal(int(os.Stdin.Fd()))
|
||||
if tty {
|
||||
stty("-icanon", "-echo")
|
||||
}
|
||||
var buf [1]byte
|
||||
n, err := os.Stdin.Read(buf[:])
|
||||
stty("icanon", "echo")
|
||||
if tty {
|
||||
drainTTY()
|
||||
stty("icanon", "echo")
|
||||
}
|
||||
key := ""
|
||||
if err == nil && n > 0 {
|
||||
key = strings.Trim(string(buf[:n]), "\r\n\t")
|
||||
@@ -37,6 +49,20 @@ func getkey(prompt string) string {
|
||||
return key
|
||||
}
|
||||
|
||||
// drainTTY discards input already queued on the terminal. `min 0 time 0` makes
|
||||
// a read return whatever is buffered without waiting, so this cannot block when
|
||||
// nothing is pending.
|
||||
func drainTTY() {
|
||||
stty("-icanon", "-echo", "min", "0", "time", "0")
|
||||
buf := make([]byte, 256)
|
||||
for {
|
||||
n, err := os.Stdin.Read(buf)
|
||||
if err != nil || n == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stty(args ...string) {
|
||||
c := exec.Command("stty", args...)
|
||||
c.Stdin = os.Stdin
|
||||
|
||||
Reference in New Issue
Block a user