Files
mgsh/git.go
T
2026-08-11 16:14:55 +02:00

235 lines
6.9 KiB
Go

package main
import (
"errors"
"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
}
// errRemoteBang is what a command carrying a '!' comes back with, so a caller
// sees a failure rather than a command that quietly did something else.
var errRemoteBang = errors.New("remote command contains '!'")
// remoteRejected reports whether a command must not be sent at all, and says
// why. '!' is the one character shq cannot protect: csh expands history *before*
// it looks at quotes, and it does so non-interactively too — `echo 'fix!now'`
// answers "Event not found" on the tcsh server. No spelling survives both csh
// and sh, so the only safe move is not to send one. This is the place every
// remote command passes.
func remoteRejected(remote string) bool {
if !strings.ContainsRune(remote, '!') {
return false
}
errorln("not sending a command with '!' in it — the server's login shell " +
"would expand it instead of passing it on: " + remote)
return true
}
// ssh runs a single remote command over ssh with inherited stdio.
func ssh(remote string) error {
if remoteRejected(remote) {
return errRemoteBang
}
_, 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) {
if remoteRejected(remote) {
return nil, errRemoteBang
}
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
}
// serverPath anchors a path on the git server at the configured gitpath.
//
// Every remote command has to name its target outright, because the login
// directory of the git user is not necessarily the directory holding the bare
// repositories. With `gituser = git` and `gitpath = /home/git` the two are the
// same place and a bare "." worked by luck; with `gituser = root` and
// `gitpath = /root/mgsh` it lists the home directory, where there is nothing to
// find.
func serverPath(rel string) string {
base := strings.TrimRight(cfg.GitPath, "/")
rel = strings.TrimPrefix(strings.TrimSpace(rel), "./")
if rel == "" || rel == "." {
return base
}
return base + "/" + rel
}
// serverRepoNames lists the bare repositories on the git server, without the
// ".git" suffix.
func serverRepoNames() ([]string, error) {
lines, err := sshOut("/bin/ls " + shq(serverPath(".")))
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 gitpath, which serverPath resolves). 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(serverPath(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)
}