Files
mgsh/main.go
T
mikeandClaude Opus 5 cd5ab1a2bd Remove the view command and the editor setting with it
`view` was the last user of `editor`, so leaving the setting behind would
have made it exactly what `gitkey` was until recently: documented,
parsed, and doing nothing. It is gone from the struct, the template, the
environment, `config -k` and the settings table.

Both names are free for aliases now, as `open` already was.

The tests that used `editor` as their example of a project-overridable
setting use `gitkey` instead, which is the same kind of thing and still
exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:11:06 +02:00

299 lines
8.6 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() {
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 <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)
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
} 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, "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,
}
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)
}