overview showed dirty and ahead/behind per project, which git can do on its own. mgsh is the only thing that sees both the local base directory and the ssh server, and joining those answers the questions git cannot: which projects were never pushed to the server (candidates for `init`), and which exist there but not on this machine (candidates for `clone`). Both lists are printed after the summary. An unreachable server is reported as such, rather than as "everything is missing". Each row also names the machine that made the last commit and how long ago. That costs nothing: `push` has always stamped "[user@host]" into the commit message, and nothing ever read it back. On a setup spanning several machines it is usually the piece one actually wanted. Rows also show which mirror targets the repository has a remote for, which is local git config and therefore free. The walk is now concurrent and cheaper per project: `git status --porcelain=v2 --branch` yields branch, upstream, ahead/behind and dirty in one subprocess where three were used before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
5.2 KiB
Go
191 lines
5.2 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, "'", `'\''`) + "'"
|
|
}
|
|
|
|
// sshExec runs one command on the git server. Every remote command mgsh issues
|
|
// — including the `rm -rf` in `init` — funnels through here, which is why it is
|
|
// a variable: tests replace it with a recorder and assert on exactly what would
|
|
// reach the server. With capture set the output is returned, otherwise the
|
|
// command inherits mgsh's stdio.
|
|
var sshExec = func(remote string, capture bool) ([]byte, error) {
|
|
args := sshArgs(cfg.GitUser+"@"+cfg.GitHost, remote)
|
|
c := exec.Command("ssh", args...)
|
|
if !capture {
|
|
c.Stdin = os.Stdin
|
|
c.Stdout = os.Stdout
|
|
c.Stderr = os.Stderr
|
|
return nil, c.Run()
|
|
}
|
|
c.Stderr = os.Stderr
|
|
out, err := c.Output()
|
|
return out, err
|
|
}
|
|
|
|
// ssh runs a single remote command over ssh with inherited stdio.
|
|
func ssh(remote string) error {
|
|
_, err := sshExec(remote, false)
|
|
return err
|
|
}
|
|
|
|
// 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) {
|
|
out, err := sshExec(remote, true)
|
|
lines := strings.Split(string(out), "\n")
|
|
for len(lines) > 0 && lines[len(lines)-1] == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
return lines, err
|
|
}
|
|
|
|
// serverRepoNames lists the bare repositories on the git server, without the
|
|
// ".git" suffix.
|
|
func serverRepoNames() ([]string, error) {
|
|
lines, err := sshOut("/bin/ls .")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []string
|
|
for _, ln := range lines {
|
|
if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil {
|
|
out = append(out, m[1])
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// 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)
|
|
}
|