Files
mgsh/main.go
T

210 lines
5.7 KiB
Go

// mgsh (git shell) — Go port of the original Perl mgsh (mwx'2026).
//
// A small interactive shell / command-line wrapper around a self-hosted bare
// git server, reachable over ssh. It manages a flat set of projects living
// under a base directory (default $HOME/src, or /db/src on Linux).
//
// Configuration lives in ~/.mgshrc and/or MGSH_* environment variables; see
// config.go and the README.
package main
import (
"fmt"
"io"
"os"
"os/user"
"path/filepath"
"strings"
"github.com/chzyer/readline"
)
// VERSION is a var (not const) so the build script can inject the current build
// number via -ldflags "-X main.VERSION=...". The default is used for a plain
// `go build`.
var VERSION = "4.0.0"
const INFO = "mwx'2026"
// Global runtime state.
var (
cfg Config // resolved configuration
BASE string // project base directory (cfg.Base)
URL string // ssh:// clone/push URL
PRJ string // current project (may be empty)
DIR string // current working directory (BASE or BASE/PRJ)
IP string // local IP, embedded into the initial commit message
HOST string // short hostname
USER string // current user name
REPO string // repository name parsed from remote.origin.url
BPLSTATE string // "/OFF" if build.pl symlink points at a *.off file
BRANCH string // current git branch of the active project (if any)
DIRTY bool // whether the active project has uncommitted changes
useColor bool // whether to render ANSI colors
)
func main() {
setup()
useColor = readline.IsTerminal(int(os.Stdout.Fd()))
fmt.Println(banner())
// preset project from the first argument (interactive launch: `mgsh myproj`)
if len(os.Args) > 1 && isDir(BASE+"/"+os.Args[1]) {
PRJ = os.Args[1]
}
// command-line mode: `mgsh <command> [args...]`
clCmd, cmdline, isHelp := parseArgs()
if isHelp {
help()
return
}
if clCmd != 0 {
pwd, _ := os.Getwd()
PRJ = ""
if strings.HasPrefix(pwd, BASE+"/") {
PRJ = pwd[len(BASE)+1:]
}
if clCmd == 2 {
cmdline = strings.TrimSpace(cmdline + " " + PRJ)
}
updateDirState()
runCommand(cmdline)
return
}
runInteractive()
}
// runInteractive drives the colored, history- and completion-enabled REPL.
func runInteractive() {
home, _ := os.UserHomeDir()
rl, err := readline.NewEx(&readline.Config{
Prompt: promptString(),
HistoryFile: filepath.Join(home, ".mgsh_history"),
AutoComplete: completer(),
InterruptPrompt: "^C",
EOFPrompt: "quit",
HistorySearchFold: true,
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer rl.Close()
for {
updateDirState()
rl.SetPrompt(promptString())
line, err := rl.Readline()
if err == readline.ErrInterrupt { // Ctrl-C: clear a typed line, else quit
if len(line) == 0 {
break
}
continue
} else if err == io.EOF { // Ctrl-D
break
}
if !runCommand(strings.TrimSpace(line)) {
break
}
}
}
// parseArgs mirrors the original command-line dispatch table. It returns the
// "CLCMD" class (0 = interactive, 1 = plain, 2 = append project name), the raw
// command line, and whether `help` was requested.
func parseArgs() (int, string, bool) {
if len(os.Args) < 2 {
return 0, "", false
}
a0 := os.Args[1]
if a0 == "help" {
return 0, "", true
}
cls := map[string]int{
"clone": 2, "init": 2, "log": 2,
"push": 1, "pushremote": 1, "list": 1, "tag": 1, "archive": 1, "show": 1, "open": 1,
"pull": 1, "fetch": 1, "status": 1, "diff": 1, "overview": 1,
}
if c, ok := cls[a0]; ok {
return c, strings.Join(os.Args[1:], " "), false
}
return 0, "", false
}
func setup() {
cfg = loadConfig()
if miss := cfg.missingRequired(); len(miss) > 0 {
fmt.Fprintf(os.Stderr, "mgsh: not configured — missing required settings: %s\n", strings.Join(miss, ", "))
fmt.Fprintf(os.Stderr, "edit %s or set the corresponding MGSH_* environment variables, then run mgsh again.\n", configFile())
os.Exit(1)
}
BASE = cfg.Base
if !isDir(BASE) {
fmt.Fprintf(os.Stderr, "%s not found\n", BASE)
os.Exit(1)
}
URL = fmt.Sprintf("ssh://%s@%s:%s%s", cfg.GitUser, cfg.GitHost, cfg.GitPort, cfg.GitPath)
IP = resolveIP()
HOST = shortHostname()
if u, err := user.Current(); err == nil {
USER = u.Username
}
// git identity / settings, from config (defaults preserve prior behavior)
if cfg.GitName != "" {
git("", "config", "--global", "user.name", cfg.GitName)
}
if cfg.GitEmail != "" {
git("", "config", "--global", "user.email", cfg.GitEmail)
}
if cfg.PushDefault != "" {
git("", "config", "--global", "push.default", cfg.PushDefault)
}
loadAliases()
}
// updateDirState recomputes DIR, the build.pl OFF marker, the current branch /
// dirty flag and the parsed repository name for the active project. Run once
// per loop iteration.
func updateDirState() {
BRANCH = ""
DIRTY = false
if PRJ != "" && isDir(BASE+"/"+PRJ) {
DIR = BASE + "/" + PRJ
BPLSTATE = ""
bp := DIR + "/build.pl"
if fi, err := os.Lstat(bp); err == nil && fi.Mode()&os.ModeSymlink != 0 {
if target, err := os.Readlink(bp); err == nil && strings.HasSuffix(target, ".off") {
BPLSTATE = "/OFF"
}
}
if isDir(DIR + "/.git") {
if out, err := gitCapture(DIR, "rev-parse", "--abbrev-ref", "HEAD"); err == nil {
BRANCH = strings.TrimSpace(out)
}
if out, err := gitCapture(DIR, "status", "--porcelain"); err == nil && strings.TrimSpace(out) != "" {
DIRTY = true
}
}
} else {
DIR = BASE
BPLSTATE = ""
}
// derive REPO from the origin URL: basename without the ".git" suffix.
REPO = ""
if out, err := gitCapture(DIR, "config", "remote.origin.url"); err == nil {
remurl := strings.TrimRight(strings.TrimSpace(out), "/")
if remurl != "" {
REPO = strings.TrimSuffix(filepath.Base(remurl), ".git")
}
}
}