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>
73 lines
1.6 KiB
Go
73 lines
1.6 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/chzyer/readline"
|
|
)
|
|
|
|
// yesno asks a y/n question with a default. Reads a single keypress.
|
|
func yesno(prompt string, def bool) bool {
|
|
suffix := " y/N ? "
|
|
if def {
|
|
suffix = " Y/n ? "
|
|
}
|
|
ans := strings.ToLower(strings.TrimSpace(getkey(prompt + suffix)))
|
|
if ans == "" {
|
|
return def
|
|
}
|
|
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)
|
|
tty := readline.IsTerminal(int(os.Stdin.Fd()))
|
|
if tty {
|
|
stty("-icanon", "-echo")
|
|
}
|
|
var buf [1]byte
|
|
n, err := os.Stdin.Read(buf[:])
|
|
if tty {
|
|
drainTTY()
|
|
stty("icanon", "echo")
|
|
}
|
|
key := ""
|
|
if err == nil && n > 0 {
|
|
key = strings.Trim(string(buf[:n]), "\r\n\t")
|
|
}
|
|
fmt.Println(key)
|
|
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
|
|
c.Stdout = os.Stdout
|
|
c.Stderr = os.Stderr
|
|
c.Run()
|
|
}
|