210 lines
5.7 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|