// 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 ( baseCfg Config // global configuration: ~/.mgshrc + MGSH_* cfg Config // effective configuration: baseCfg + the project's .mgshrc cfgPRJ string // project whose .mgshrc is currently applied cfgFresh bool // whether cfg has been resolved at least once BASE string // project base directory (baseCfg.Base) URL string // ssh:// clone/push URL PRJ string // current project (may be empty) DIR string // current working directory (BASE or BASE/PRJ) HOST string // short hostname USER string // current user name 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() { // Answered before setup(), which exits when nothing is configured yet: an // update has to work on a machine that has never run mgsh, and `--version` // is what the freshly downloaded binary is probed with, right there. if updateFlags() { return } 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 && validProject(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 { PRJ = projectFromCwd() if clCmd == 2 { cmdline = strings.TrimSpace(cmdline + " " + PRJ) } updateDirState() runCommand(cmdline) updateNote() // after the output: a footer, not a headline return } updateNote() // before the prompt: a session starts here, not when it ends runInteractive() } // updateFlags answers the self-update options and reports whether it did. They // are deliberately spelled with dashes and kept out of parseArgs: `mgsh update` // is the command for everyday use, and these are what works when there is no // configuration to read yet. func updateFlags() bool { if len(os.Args) < 2 { return false } switch os.Args[1] { case "--version": fmt.Printf("mgsh %s\n", VERSION) case "--update": if err := selfUpdate.install(os.Stdout); err != nil { fmt.Fprintf(os.Stderr, "mgsh: %v\n", err) os.Exit(1) } case "--check-update": if err := selfUpdate.check(os.Stdout); err != nil { fmt.Fprintf(os.Stderr, "mgsh: %v\n", err) os.Exit(1) } case updateRefreshFlag: // the background run, not in the help selfUpdate.refresh() default: return false } return true } // updateNote prints the once-a-day hint, when there is one. It costs nothing: // the line comes from the note in the cache directory, and the asking behind it // happens in the background, at most once a day. func updateNote() { if hint := selfUpdate.daily(); hint != "" { fmt.Fprintln(os.Stderr, col(cDark, hint)) } } // 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 } else if err != nil { // detached/broken terminal: don't spin fmt.Fprintln(os.Stderr, err) break } if !runCommand(strings.TrimSpace(line)) { break } } } // projectFromCwd derives the active project from the working directory: the // first path element below BASE, so running `mgsh push` from deep inside a // project still addresses the project itself and not the subdirectory. // Returns "" when the working directory is outside BASE. func projectFromCwd() string { pwd, err := os.Getwd() if err != nil || !strings.HasPrefix(pwd, BASE+"/") { return "" } rel := pwd[len(BASE)+1:] if i := strings.IndexByte(rel, '/'); i >= 0 { rel = rel[:i] } if !validProject(rel) { return "" } return rel } // 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, "deleteremote": 1, "list": 1, "tag": 1, "archive": 1, "show": 1, "pull": 1, "fetch": 1, "status": 1, "diff": 1, "overview": 1, "config": 1, "count": 1, "login": 1, "cloneall": 1, "release": 1, "update": 1, } if c, ok := cls[a0]; ok { return c, strings.Join(os.Args[1:], " "), false } return 0, "", false } func setup() { // the *global* configuration has to stand on its own: mgsh must be usable // outside any project, where no project .mgshrc can fill in the blanks. baseCfg = loadConfig() if miss := baseCfg.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 = baseCfg.Base if !isDir(BASE) { fmt.Fprintf(os.Stderr, "%s not found\n", BASE) os.Exit(1) } applyProjectConfig() // no project yet: cfg = baseCfg, URL from it HOST = shortHostname() if u, err := user.Current(); err == nil { USER = u.Username } applyGlobalGitConfig() loadAliases() } // applyGlobalGitConfig writes the configured git identity to ~/.gitconfig, but // only the values that actually differ — every mgsh invocation runs this, and a // plain `mgsh status` has no business rewriting the user's global config. The // current settings are read in one go rather than one process per key. func applyGlobalGitConfig() { want := map[string]string{} if baseCfg.GitName != "" { want["user.name"] = baseCfg.GitName } if baseCfg.GitEmail != "" { want["user.email"] = baseCfg.GitEmail } if baseCfg.PushDefault != "" { want["push.default"] = baseCfg.PushDefault } if len(want) == 0 { return } have := map[string]string{} if out, err := gitCapture("", "config", "--global", "--list"); err == nil { for _, ln := range splitLines(out) { if i := strings.IndexByte(ln, '='); i > 0 { have[ln[:i]] = ln[i+1:] } } } for k, v := range want { if have[k] != v { git("", "config", "--global", k, v) } } } // applyProjectConfig re-resolves cfg whenever the active project changes: the // project's own .mgshrc overrides the global settings, so a single project can // live on a different server or mirror to a different place. Resolving once per // project change rather than once per prompt keeps the REPL loop free of I/O. func applyProjectConfig() { if cfgFresh && PRJ == cfgPRJ { return } cfgFresh, cfgPRJ = true, PRJ dir := "" if PRJ != "" && isDir(BASE+"/"+PRJ) { dir = BASE + "/" + PRJ } cfg = resolveConfig(baseCfg, dir) URL = fmt.Sprintf("ssh://%s@%s:%s%s", cfg.GitUser, cfg.GitHost, cfg.GitPort, cfg.GitPath) } // reloadConfig re-reads ~/.mgshrc and the project's .mgshrc, so an edit takes // effect without restarting the shell. An edit that breaks the global config is // rejected rather than applied — the running shell keeps working. func reloadConfig() { fresh := loadConfig() if miss := fresh.missingRequired(); len(miss) > 0 { errorln("keeping the previous configuration — " + configFile() + " is missing: " + strings.Join(miss, ", ")) return } if fresh.Base != BASE { errorln("'base' changed to " + fresh.Base + " — restart mgsh to use it") fresh.Base = BASE } baseCfg = fresh cfgFresh = false applyProjectConfig() } // updateDirState recomputes DIR, the build.pl OFF marker and the current // branch / dirty flag for the active project. Run once per loop iteration, so // it stays deliberately cheap. func updateDirState() { BRANCH = "" DIRTY = false applyProjectConfig() 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 = "" } } // originURL returns the active project's own origin URL, or "" when it is not a // repository or has no origin. `--local` matters: a plain `git config` walks up // into an enclosing repository and would report a foreign origin for a project // that has no repository of its own. func originURL() string { if !isDir(DIR + "/.git") { return "" } out, err := gitCapture(DIR, "config", "--local", "remote.origin.url") if err != nil { return "" } return strings.TrimSpace(out) }