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>
157 lines
4.3 KiB
Go
157 lines
4.3 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// runInDir runs a command with inherited stdio, optionally in dir.
|
|
func runInDir(dir, name string, args ...string) error {
|
|
c := exec.Command(name, args...)
|
|
if dir != "" {
|
|
c.Dir = dir
|
|
}
|
|
c.Stdin = os.Stdin
|
|
c.Stdout = os.Stdout
|
|
c.Stderr = os.Stderr
|
|
return c.Run()
|
|
}
|
|
|
|
// runQuiet runs a command in dir with its output discarded, for probes whose
|
|
// exit status is the only interesting part.
|
|
func runQuiet(dir, name string, args ...string) error {
|
|
c := exec.Command(name, args...)
|
|
c.Dir = dir
|
|
return c.Run()
|
|
}
|
|
|
|
// sshKeyPath returns the identity file to authenticate with at the git server,
|
|
// or "" when no 'gitkey' is configured. A bare name is looked up in ~/.ssh, a
|
|
// path (absolute or ~/-relative) is used as given.
|
|
func sshKeyPath() string {
|
|
k := strings.TrimSpace(cfg.GitKey)
|
|
if k == "" {
|
|
return ""
|
|
}
|
|
home, _ := os.UserHomeDir()
|
|
switch {
|
|
case strings.HasPrefix(k, "~/"):
|
|
return filepath.Join(home, k[2:])
|
|
case strings.ContainsRune(k, '/'):
|
|
return k
|
|
default:
|
|
return filepath.Join(home, ".ssh", k)
|
|
}
|
|
}
|
|
|
|
// sshArgs prefixes the ssh options shared by every connection to the git
|
|
// server: the configured port and, when set, the identity file.
|
|
func sshArgs(rest ...string) []string {
|
|
args := []string{"-p", cfg.GitPort}
|
|
if k := sshKeyPath(); k != "" {
|
|
args = append(args, "-i", k)
|
|
}
|
|
return append(args, rest...)
|
|
}
|
|
|
|
// gitEnv is the environment for a git subprocess: nil (inherit) unless a
|
|
// 'gitkey' is configured, in which case git's ssh transport is pointed at it.
|
|
// Set per command instead of in mgsh's own environment, so a `!git ...` shell
|
|
// escape keeps whatever the user's shell would normally do.
|
|
func gitEnv() []string {
|
|
k := sshKeyPath()
|
|
if k == "" {
|
|
return nil
|
|
}
|
|
return append(os.Environ(), "GIT_SSH_COMMAND=ssh -i "+shq(k))
|
|
}
|
|
|
|
// git runs a git command with inherited stdio, optionally in dir.
|
|
func git(dir string, args ...string) error {
|
|
c := exec.Command("git", args...)
|
|
if dir != "" {
|
|
c.Dir = dir
|
|
}
|
|
c.Stdin = os.Stdin
|
|
c.Stdout = os.Stdout
|
|
c.Stderr = os.Stderr
|
|
c.Env = gitEnv()
|
|
return c.Run()
|
|
}
|
|
|
|
// gitOK runs git and, on failure, prints a red summary. Returns success.
|
|
func gitOK(dir string, args ...string) bool {
|
|
if err := git(dir, args...); err != nil {
|
|
errorln("git " + strings.Join(args, " ") + " failed")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func gitCapture(dir string, args ...string) (string, error) {
|
|
c := exec.Command("git", args...)
|
|
if dir != "" {
|
|
c.Dir = dir
|
|
}
|
|
c.Env = gitEnv()
|
|
out, err := c.Output()
|
|
return string(out), err
|
|
}
|
|
|
|
// shq quotes s for interpolation into a remote /bin/sh command line. Every
|
|
// remote command is a single string handed to the login shell, so any project
|
|
// name or configured path reaching it must go through here — otherwise a space
|
|
// splits one argument into two and a backtick runs on the server.
|
|
func shq(s string) string {
|
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
}
|
|
|
|
// ssh runs a single remote command over ssh with inherited stdio.
|
|
func ssh(remote string) error {
|
|
return runInDir("", "ssh", sshArgs(cfg.GitUser+"@"+cfg.GitHost, remote)...)
|
|
}
|
|
|
|
// sshOK runs a remote command and, on failure, prints a red summary.
|
|
func sshOK(remote string) bool {
|
|
if err := ssh(remote); err != nil {
|
|
errorln("ssh failed: " + remote)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// sshOut runs a remote command and returns its stdout split into lines.
|
|
func sshOut(remote string) ([]string, error) {
|
|
c := exec.Command("ssh", sshArgs(cfg.GitUser+"@"+cfg.GitHost, remote)...)
|
|
c.Stderr = os.Stderr
|
|
out, err := c.Output()
|
|
lines := strings.Split(string(out), "\n")
|
|
for len(lines) > 0 && lines[len(lines)-1] == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
return lines, err
|
|
}
|
|
|
|
// serverEntryExists reports whether entry is present in the remote directory
|
|
// path (relative to the git user's home). The error is returned rather than
|
|
// folded into the bool so a failed lookup is never mistaken for "not there".
|
|
func serverEntryExists(path, entry string) (bool, error) {
|
|
lines, err := sshOut("/bin/ls " + shq(path))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, ln := range lines {
|
|
if strings.TrimSpace(ln) == entry {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// forwardShell runs a line via /bin/sh -c in the active project directory.
|
|
func forwardShell(line string) {
|
|
runInDir(DIR, "/bin/sh", "-c", line)
|
|
}
|