Files
Michael Wesemann cad7a4ec2c Turn overview into an inventory across local and server
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>
2026-07-26 16:17:40 +02:00

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)
}