352 lines
10 KiB
Go
352 lines
10 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 (
|
|
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
|
|
}
|
|
|
|
// Colour first, then setup(): the configuration warnings setup() prints are
|
|
// the first thing on screen, and they were the only messages coming out
|
|
// uncoloured — col() saw useColor still false while they were written.
|
|
// Nothing about the terminal depends on the configuration, so the question
|
|
// can just as well be asked one line earlier.
|
|
useColor = readline.IsTerminal(int(os.Stdout.Fd()))
|
|
setup()
|
|
|
|
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 <command> [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)
|
|
}
|