// 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 [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") } } }