From 5562055695fce4797457792fcac4ae11468a7420 Mon Sep 17 00:00:00 2001 From: Michael Wesemann Date: Sun, 26 Jul 2026 06:38:01 +0200 Subject: [PATCH] initial commit [141.14.129.234,mike] --- .gitignore | 10 + README.md | 156 ++++++++++++ alias.go | 289 ++++++++++++++++++++++ build.sh | 23 ++ colors.go | 59 +++++ commands.go | 646 +++++++++++++++++++++++++++++++++++++++++++++++++ completion.go | 166 +++++++++++++ config.go | 177 ++++++++++++++ git.go | 72 ++++++ go.mod | 7 + go.sum | 8 + input.go | 46 ++++ main.go | 209 ++++++++++++++++ mgsh_test.go | 423 ++++++++++++++++++++++++++++++++ mgshrc.example | 34 +++ overview.go | 108 +++++++++ prompt.go | 65 +++++ remote.go | 305 +++++++++++++++++++++++ util.go | 94 +++++++ version.txt | 1 + 20 files changed, 2898 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 alias.go create mode 100755 build.sh create mode 100644 colors.go create mode 100644 commands.go create mode 100644 completion.go create mode 100644 config.go create mode 100644 git.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 input.go create mode 100644 main.go create mode 100644 mgsh_test.go create mode 100644 mgshrc.example create mode 100644 overview.go create mode 100644 prompt.go create mode 100644 remote.go create mode 100644 util.go create mode 100644 version.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..529a5b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +.AppleDouble +.LSOverride +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +mgsh diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a0b7ed --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# mgsh — git shell + +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). Go port of the original +Perl `mgsh` (`mgsh.perl`). + +## Build + +```sh +./build.sh # builds ./mgsh and bumps the patch version by 0.0.1 +go build -o mgsh . # plain build, keeps the default version +``` + +`build.sh` reads `version.txt`, increments the patch component, injects it via +`-ldflags -X main.VERSION`, and writes it back — so `version.txt` always holds +the version of the binary just built. Dependencies are fetched via Go modules +(`go.mod` / `go.sum`) on first build. + +Run the tests with `go test ./...`. + +## Usage + +Launch `mgsh` for the interactive shell, or run a single command directly from a +project directory, e.g. `mgsh push "message"`, `mgsh status`, `mgsh log`. + +The interactive prompt is colored (Catppuccin Mocha) and shows the active +project, its git branch and a `*` dirty marker: + +``` +< src/myproject (master*) > +``` + +Features: command history (`~/.mgsh_history`), Tab completion (commands, local +projects for `cd`/`open`, server repos for `clone`/`show`, branches/tags for +`checkout`/`tag`, filesystem paths for `dist`), and colored `list`/`log`/error +output. + +Exit with `quit`, `exit`, `Ctrl-D`, or `Ctrl-C` on an empty line. + +### Shell escape + +Unknown commands are **not** forwarded to a shell. To run a shell command, +prefix it with `!`: + +``` +< src/myproject > !ls -la +``` + +### Commands + +Run `help` for the full list. Highlights: + +| command | description | +|---------------------------|------------------------------------------------| +| `cd [project]` | change project | +| `push [comment]` | commit everything and push to the server | +| `pushremote [desc]` | mirror the repo to a public server (gitea/github/gitlab) | +| `pull` / `fetch` | pull / fetch from the server | +| `status [-a]` / `diff` | short git status (`-a`: overview of all projects) | +| `overview` | dirty / ahead-behind summary of all projects | +| `log` | show the project log | +| `edit [n]` | interactive rebase of the last n commits | +| `clone [-a] ` | clone a repository (or archive) from the server | +| `list [-a] [pattern]` | list repositories on the server | +| `show ` | show a repository log directly on the server | +| `archive [comment]` | snapshot the server-side repo into `./archive` | +| `init` | make a new repository from the current directory | +| `tag [add/checkout/delete]` | manage tags | +| `alias [name [cmd]]` | list, show or define a command alias | +| `unalias ` | remove a command alias | +| `rescan` | refresh the cached server repository list | +| `!` | run `` in the shell | + +### Aliases + +`alias ''` defines a reusable shortcut, persisted to +`~/.mgshrc` and reloaded on every start. The expansion is itself a mgsh +command line and may reference the alias arguments: + +| placeholder | meaning | +|-------------|----------------------------------| +| `$1` … `$N` | the Nth argument (empty if unset)| +| `$*` / `$@` | all arguments, space-joined | + +When the expansion contains no placeholder, the arguments are appended (classic +shell-alias behaviour). Because unknown commands are **not** forwarded to a +shell, a shell command inside an alias needs the `!` prefix: + +``` +alias co 'checkout $1' # co v2 -> checkout v2 (builtin) +alias p 'push $*' # p fixed bug -> push fixed bug (builtin) +alias ec '!echo $1' # ec hello -> echo hello (shell) +``` + +`alias` with no arguments lists all aliases, `alias ` shows one, and +`unalias ` removes it. Aliases cannot shadow builtin commands. + +### Public mirror (`pushremote`) + +Besides the internal ssh git server, `pushremote` mirrors the active project to +a public hosting server (Gitea, GitHub or GitLab) over its REST API. It reads +two settings from `~/.mgshrc`: + +```ini +remoteurl = https://git.example.com # base URL of the server +remotekey = # API token +# remotetype = gitea # optional; auto-detected from remoteurl +# remotevisibility = private # visibility of created repos (default private) +# mirror = true # `push` also mirrors via pushremote +``` + +`pushremote` authenticates with the token, creates the repository (named after +the current project) if it does not exist yet, adds a credential-free remote +named `public`, and pushes all branches and tags. New repositories are +**private** unless `remotevisibility = public`; any words after the command +(`pushremote `) are set as the repository description on creation. +The token is sent as a one-shot HTTP auth header, never written into the repo's +git config. The provider is auto-detected from `remoteurl` (`github.com` → +GitHub, `gitlab*` → GitLab, otherwise Gitea) and can be forced with +`remotetype`. Set `mirror = true` to have every `push` mirror automatically. + +## Configuration + +mgsh has **no built-in defaults**. Configuration comes entirely from `~/.mgshrc` +(overlaid with `MGSH_*` environment variables). On first run mgsh writes a blank, +annotated `~/.mgshrc` template (migrating any aliases from a pre-4.x +`~/.mgsh_aliases`) and then exits with an error until the required settings — +`base`, `githost`, `gitport`, `gituser`, `gitpath` — are filled in. + +It uses simple `key = value` (or `key: value`) lines (`#` comments allowed); +alias definitions live in the same file: + +```ini +# --- required --- +base = /Users/me/src +githost = git.example.com +gitport = 22 +gituser = git +gitpath = /home/git + +# --- optional --- +gitname = Your Name +gitemail = you@example.com +pushdefault = matching +editor = code # fallback opener for `open` + +alias co 'checkout $1' +``` + +Environment overrides: `MGSH_BASE`, `MGSH_GITHOST`, `MGSH_GITPORT`, +`MGSH_GITUSER`, `MGSH_GITPATH`, `MGSH_GITKEY`, `MGSH_GITNAME`, `MGSH_GITEMAIL`, +`MGSH_PUSHDEFAULT`, `MGSH_EDITOR`, `MGSH_REMOTEURL`, `MGSH_REMOTEKEY`, +`MGSH_REMOTETYPE`, `MGSH_REMOTEVISIBILITY`, `MGSH_MIRROR`. + +See `mgshrc.example` for an annotated template. diff --git a/alias.go b/alias.go new file mode 100644 index 0000000..dcb3f10 --- /dev/null +++ b/alias.go @@ -0,0 +1,289 @@ +package main + +// alias.go — user-defined command aliases, persisted in ~/.mgshrc. +// +// An alias maps a name to an expansion template, which is itself a mgsh command +// line. The template may reference the arguments passed to the alias: +// +// $1 … $N the Nth argument ("" when missing) +// $* $@ all arguments, space-joined +// +// When the template contains no placeholder, the arguments are appended (the +// classic shell-alias behaviour). Since mgsh does not forward unknown commands +// to a shell, a shell command inside an alias needs the '!' prefix, e.g. +// +// alias ec '!echo $1' ec hello -> runs `echo hello` +// alias co 'checkout $1' co v2 -> runs the `checkout` builtin +// alias p 'push $*' p fixed bug -> runs `push fixed bug` + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// aliases maps an alias name to its expansion template. Loaded at startup and +// re-persisted on every change. +var aliases = map[string]string{} + +// maxAliasDepth bounds recursive alias expansion (guards against cycles). +const maxAliasDepth = 16 + +var ( + aliasNameRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + aliasVarRe = regexp.MustCompile(`\$(\d+|\*|@)`) +) + +// builtinCmds is the set of reserved command words that cannot be shadowed by +// an alias (mirrors the switch in runCommand). +var builtinCmds = map[string]bool{ + "": true, "quit": true, "exit": true, "help": true, "rescan": true, + "dist": true, "list": true, "show": true, "log": true, "status": true, + "diff": true, "pull": true, "fetch": true, "push": true, "edit": true, + "pushremote": true, "overview": true, "archive": true, "init": true, + "login": true, "cd": true, "checkout": true, "clone": true, "cloneall": true, + "open": true, "view": true, "count": true, "tag": true, "alias": true, + "unalias": true, +} + +func isBuiltin(name string) bool { return builtinCmds[name] } + +// aliasHeader labels the managed alias block inside ~/.mgshrc. +const aliasHeader = "# aliases — managed by the `alias` command; format: alias ''" + +// legacyAliasFile is the pre-4.x standalone alias file, migrated into ~/.mgshrc +// on first run. +func legacyAliasFile() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".mgsh_aliases") +} + +// loadAliases reads alias definitions from ~/.mgshrc. Builtins are skipped so a +// hand-edited file can never shadow a real command. +func loadAliases() { + data, err := os.ReadFile(configFile()) + if err != nil { + return + } + for _, ln := range strings.Split(string(data), "\n") { + if name, body, ok := parseAliasLine(ln); ok && !isBuiltin(name) { + aliases[name] = body + } + } +} + +// parseAliasLine parses one "alias ''" line. ok is false for any +// line that is not an alias definition. +func parseAliasLine(ln string) (name, body string, ok bool) { + t := strings.TrimSpace(ln) + if f := strings.Fields(t); len(f) < 2 || f[0] != "alias" { + return "", "", false + } + rest := strings.TrimSpace(strings.TrimPrefix(t, "alias")) + i := strings.IndexAny(rest, " \t") + if i < 0 { + return "", "", false + } + name = rest[:i] + body = unquote(strings.TrimSpace(rest[i+1:])) + if name == "" || body == "" { + return "", "", false + } + return name, body, true +} + +// readLegacyAliases parses the old ~/.mgsh_aliases file ("namebody" +// lines), returning nil when it does not exist. +func readLegacyAliases() map[string]string { + data, err := os.ReadFile(legacyAliasFile()) + if err != nil { + return nil + } + m := map[string]string{} + for _, ln := range strings.Split(string(data), "\n") { + t := strings.TrimSpace(ln) + if t == "" || strings.HasPrefix(t, "#") { + continue + } + i := strings.IndexAny(t, " \t") + if i < 0 { + continue + } + name := t[:i] + body := strings.TrimSpace(t[i+1:]) + if name != "" && body != "" && !isBuiltin(name) { + m[name] = body + } + } + return m +} + +// aliasBlock renders the managed alias section for a set of aliases (sorted by +// name), or "" when there are none. +func aliasBlock(m map[string]string) string { + if len(m) == 0 { + return "" + } + names := make([]string, 0, len(m)) + for n := range m { + names = append(names, n) + } + sort.Strings(names) + var b strings.Builder + b.WriteString(aliasHeader + "\n") + for _, n := range names { + fmt.Fprintf(&b, "alias %s '%s'\n", n, m[n]) + } + return b.String() +} + +// saveAliases rewrites ~/.mgshrc, preserving the configuration lines and +// replacing the managed alias block with the current alias set. +func saveAliases() { + path := configFile() + var keep []string + if data, err := os.ReadFile(path); err == nil { + for _, ln := range strings.Split(string(data), "\n") { + if strings.TrimSpace(ln) == aliasHeader { + continue + } + if _, _, ok := parseAliasLine(ln); ok { + continue + } + keep = append(keep, ln) + } + } + // drop any trailing blank lines so the block isn't pushed down over time + for len(keep) > 0 && strings.TrimSpace(keep[len(keep)-1]) == "" { + keep = keep[:len(keep)-1] + } + + var b strings.Builder + for _, ln := range keep { + b.WriteString(ln + "\n") + } + if blk := aliasBlock(aliases); blk != "" { + b.WriteString("\n" + blk) + } + if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil { + errorln("could not save aliases: " + err.Error()) + } +} + +// aliasNames returns the alias names, sorted. +func aliasNames() []string { + names := make([]string, 0, len(aliases)) + for n := range aliases { + names = append(names, n) + } + sort.Strings(names) + return names +} + +// handleAlias implements the `alias` command: +// +// alias list all aliases +// alias show a single alias +// alias define an alias (surrounding quotes on optional) +func handleAlias(line string) { + rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "alias")) + if rest == "" { + listAliases() + return + } + + i := strings.IndexAny(rest, " \t") + if i < 0 { // just a name: show it + if body, ok := aliases[rest]; ok { + fmt.Println(formatAlias(rest, body)) + } else { + errorln("alias not defined: " + rest) + } + return + } + + name := rest[:i] + body := unquote(strings.TrimSpace(rest[i+1:])) + switch { + case !aliasNameRe.MatchString(name): + errorln("invalid alias name: " + name) + case isBuiltin(name): + errorln("cannot alias builtin command: " + name) + case body == "": + errorln("empty alias definition") + default: + aliases[name] = body + saveAliases() + fmt.Println(formatAlias(name, body)) + } +} + +// handleUnalias removes an alias by name. +func handleUnalias(name string) { + if name == "" { + errorln("usage: unalias ") + return + } + if _, ok := aliases[name]; !ok { + errorln("alias not defined: " + name) + return + } + delete(aliases, name) + saveAliases() + fmt.Println("removed alias " + col(cGreen, name)) +} + +func listAliases() { + if len(aliases) == 0 { + fmt.Println(col(cGray, "no aliases defined")) + return + } + for _, n := range aliasNames() { + fmt.Println(formatAlias(n, aliases[n])) + } +} + +// formatAlias renders `name = 'body'` with color. +func formatAlias(name, body string) string { + return col(cGreen, name) + col(cGray, " = ") + col(cYellow, "'"+body+"'") +} + +// expandAlias substitutes positional parameters in an alias body. With no +// placeholder present, the arguments are appended instead. +func expandAlias(body string, args []string) string { + all := strings.Join(args, " ") + used := false + out := aliasVarRe.ReplaceAllStringFunc(body, func(m string) string { + used = true + tok := m[1:] + if tok == "*" || tok == "@" { + return all + } + n, _ := strconv.Atoi(tok) + if n >= 1 && n <= len(args) { + return args[n-1] + } + return "" + }) + if !used && len(args) > 0 { + out = strings.TrimRight(out, " ") + " " + all + } + return strings.TrimSpace(out) +} + +// unquote strips one pair of matching surrounding single or double quotes. +func unquote(s string) string { + if len(s) >= 2 { + if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') { + return s[1 : len(s)-1] + } + } + return s +} + +// dynAliasNames feeds Tab completion for `alias`/`unalias`. +func dynAliasNames(string) []string { return aliasNames() } diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..419832b --- /dev/null +++ b/build.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Build mgsh, auto-incrementing the patch version by 0.0.1 on every build. +# +# version.txt holds the currently built version. Each run increments the patch +# component, then builds with that version injected via -ldflags, and writes it +# back. So version.txt always reflects the version of the binary just built. +set -e +cd "$(dirname "$0")" + +V=$(cat version.txt 2>/dev/null || echo 4.0.0) + +# split MAJOR.MINOR.PATCH and increment PATCH (no carry: 4.0.9 -> 4.0.10) +MAJOR=${V%%.*} +REST=${V#*.} +MINOR=${REST%%.*} +PATCH=${REST#*.} +PATCH=$((PATCH + 1)) +NV="$MAJOR.$MINOR.$PATCH" + +go build -ldflags "-X main.VERSION=$NV" -o mgsh . + +echo "$NV" > version.txt +echo "built mgsh v$NV" diff --git a/colors.go b/colors.go new file mode 100644 index 0000000..2dd51ba --- /dev/null +++ b/colors.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "strings" +) + +// Colors for the prompt, banner and output, using the Catppuccin Mocha palette +// as 24-bit truecolor escapes +// (https://terminalcolors.com/themes/catppuccin/mocha/). +const ( + cReset = "\033[0m" + cBold = "\033[1m" + cDim = "\033[2m" + + cGreen = "\033[38;2;166;227;161m" // Green #a6e3a1 + cYellow = "\033[38;2;249;226;175m" // Yellow #f9e2af + cRed = "\033[38;2;243;139;168m" // Red #f38ba8 + cCyan = "\033[38;2;148;226;213m" // Teal #94e2d5 + cPurple = "\033[38;2;203;166;247m" // Mauve #cba6f7 + cWhite = "\033[38;2;205;214;244m" // Text #cdd6f4 + cGray = "\033[38;2;108;112;134m" // Overlay0 #6c7086 +) + +// col wraps s in color c, but only when color output is enabled. +func col(c, s string) string { + if useColor { + return c + s + cReset + } + return s +} + +// errorln prints an error/status message in red (when color is enabled). +func errorln(msg string) { + fmt.Println(col(cRed, msg)) +} + +// padRight pads an ASCII string with trailing spaces to width n. +func padRight(s string, n int) string { + if len(s) < n { + return s + strings.Repeat(" ", n-len(s)) + } + return s +} + +// colorRepoLine colors a `list` entry: the leading `ls -ltr` date (3 fields) in +// yellow and the repository name in green. +func colorRepoLine(s string) string { + if !useColor { + return s + } + parts := strings.Fields(s) + if len(parts) >= 4 { + date := strings.Join(parts[:3], " ") + name := strings.Join(parts[3:], " ") + return col(cYellow, date) + " " + col(cGreen, name) + } + return col(cGreen, s) +} diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..4e62d57 --- /dev/null +++ b/commands.go @@ -0,0 +1,646 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +var ( + optRe = regexp.MustCompile(`^-(\w)$`) + numRe = regexp.MustCompile(`^\d+$`) + lsRepoRe = regexp.MustCompile(`git\s+git\s+\d+\s+(.*)\.git$`) + lsArchRe = regexp.MustCompile(`git\s+git\s+\d+\s+(.*)\.git\.tar\.gz$`) + gitDirRe = regexp.MustCompile(`^(.*)\.git$`) + sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`) + wsRe = regexp.MustCompile(`\s+`) +) + +// requireRepo checks that a project with a .git is active, printing an error +// otherwise. Returns true when it is safe to proceed. +func requireRepo() bool { + if PRJ == "" { + errorln("no project selected") + return false + } + if !isDir(DIR + "/.git") { + errorln("error, no repository (.git) found") + return false + } + return true +} + +// runCommand parses one command line and dispatches it. It returns false when +// the shell should terminate (quit/exit). +func runCommand(line string) bool { + return runCommandDepth(line, 0) +} + +// runCommandDepth is runCommand with an alias-expansion recursion counter. +func runCommandDepth(line string, depth int) bool { + line = strings.TrimSpace(line) + + // A leading '!' runs the rest of the line as a shell command. Nothing else + // is forwarded to the shell any more. + if strings.HasPrefix(line, "!") { + if sh := strings.TrimSpace(line[1:]); sh != "" { + forwardShell(sh) + } + return true + } + + // Expand a user-defined alias in the first word, then re-dispatch. The + // depth guard prevents runaway/cyclic expansion. + if depth < maxAliasDepth { + if f := strings.Fields(line); len(f) > 0 { + if body, ok := aliases[f[0]]; ok && !isBuiltin(f[0]) { + return runCommandDepth(expandAlias(body, f[1:]), depth+1) + } + } + } + + fields := strings.Fields(line) + + // separate `-x` option flags from positional words. + var words []string + opt := map[string]bool{} + for _, f := range fields { + if m := optRe.FindStringSubmatch(f); m != nil { + opt[m[1]] = true + } else { + words = append(words, f) + } + } + + switch word(words, 0) { + + case "": // empty line or options only — nothing to do + + case "quit", "exit": + return false + + case "help": + help() + + case "alias": // list, show or define a command alias (persisted) + handleAlias(line) + + case "unalias": // remove a command alias + handleUnalias(word(words, 1)) + + case "rescan": // refresh the cached server repository list + rescanServer() + fmt.Println("server repository list refreshed") + + case "dist": // cp changed files to another directory/repository + ddir := BASE + "/dist/" + PRJ + if w := word(words, 1); w != "" { + ddir = w + } + if !isDir(ddir) { + errorln(fmt.Sprintf("dist path not found (%s)", ddir)) + break + } + n := 0 + out, _ := gitCapture(DIR, "ls-files") + for _, f := range splitLines(out) { + if f == "" || strings.HasPrefix(f, "private") { + continue + } + src := filepath.Join(DIR, f) + dst := filepath.Join(ddir, f) + if !filesEqual(src, dst) { + fmt.Println(f) + if err := copyFile(src, dst); err != nil { + fmt.Fprintln(os.Stderr, col(cRed, fmt.Sprintf("copy %s: %v", f, err))) + continue + } + n++ + } + } + fmt.Printf("%d files copied to %s\n", n, ddir) + + case "list": // list repositories on the git server + path := "." + if opt["a"] { + path = "./archive" + } + pat := word(words, 1) + lines, _ := sshOut("/bin/ls -ltr " + path) + for _, ln := range lines { + if pat != "" && !strings.Contains(strings.ToLower(ln), strings.ToLower(pat)) { + continue + } + re := lsRepoRe + if opt["a"] { + re = lsArchRe + } + if m := re.FindStringSubmatch(ln); m != nil { + fmt.Println(colorRepoLine(m[1])) + } + } + + case "show": // show a repository's log directly on the server + prj := PRJ + if w := word(words, 1); w != "" { + prj = w + } + found := 0 + lines, _ := sshOut("/bin/ls .") + for _, ln := range lines { + if strings.TrimSpace(ln) == prj+".git" { + found++ + } + } + if found == 1 { + logLines, _ := sshOut("cd " + cfg.GitPath + "/" + prj + ".git && git log --reverse --format='%h %ct %s'") + repolog(logLines) + } else { + errorln("repository not found") + } + + case "log": + if !requireRepo() { + break + } + out, _ := gitCapture(DIR, "log", "--reverse", "--format=%h %ct %s") + repolog(splitLines(out)) + + case "status": // short git status of the active project (-a: all projects) + if opt["a"] { + overviewAll() + break + } + if !requireRepo() { + break + } + git(DIR, "status", "-sb") + + case "overview": // status summary of all projects under BASE + overviewAll() + + case "diff": // git diff of the active project (with optional args) + if !requireRepo() { + break + } + git(DIR, append([]string{"diff"}, fields[1:]...)...) + + case "pull": + if !requireRepo() { + break + } + gitOK(DIR, "pull") + + case "fetch": + if !requireRepo() { + break + } + gitOK(DIR, "fetch") + + case "push": // commit everything and push to the server + if PRJ == "" { + errorln("no project selected") + break + } + if fileExists(DIR + "/push.pl") { + runInDir(DIR, "perl", DIR+"/push.pl") + } + if !isDir(DIR + "/.git") { + errorln("error, no repository (.git) found") + break + } + comment := strings.Join(fields[1:], " ") + git(DIR, "add", "--all", ".") + msg := strings.TrimSpace(fmt.Sprintf("[%s@%s] %s", USER, HOST, comment)) + git(DIR, "commit", "-m", msg) // may be "nothing to commit"; continue anyway + if !gitOK(DIR, "push") { + break + } + sshOK("touch " + cfg.GitPath + "/" + PRJ + ".git") + if truthy(cfg.Mirror) && cfg.RemoteURL != "" && cfg.RemoteKey != "" { + handlePushRemote("") // auto-mirror to the public server + } + + case "pushremote": // mirror the repo to a public git server via its API + handlePushRemote(strings.Join(fields[1:], " ")) + + case "edit": // interactively edit the last N commits + if !requireRepo() { + break + } + num := "10" + if w := word(words, 1); numRe.MatchString(w) { + num = w + } + if !gitOK(DIR, "rebase", "-i", "HEAD~"+num) { + break + } + if yesno("force-push rewritten history?", false) { + gitOK(DIR, "push", "--force") + } + + case "archive": // snapshot the server-side repository into ./archive + if PRJ == "" { + errorln("no project selected") + break + } + comment := sanitizeComment(strings.Join(fields[1:], " ")) + z := archiveStamp() + name := PRJ + "_" + z + if comment != "" { + name = PRJ + "_" + z + "_" + comment + } + if !sshOK("cp -r " + PRJ + ".git archive/" + name + ".git") { + break + } + if !sshOK("cd archive;tar cvzf " + name + ".git.tar.gz " + name + ".git") { + break + } + sshOK("rm -rf archive/" + name + ".git") + + case "init": // create a new repository from the current directory + if PRJ == "" { + errorln("no project selected") + break + } + if fileExists(DIR + "/push.pl") { + runInDir(DIR, "perl", DIR+"/push.pl") + } + if REPO == "" || yesno("overwrite existing repository?", false) { + if !sshOK("rm -rf " + cfg.GitPath + "/" + PRJ + ".git") { + break + } + if !sshOK("mkdir " + cfg.GitPath + "/" + PRJ + ".git;cd " + cfg.GitPath + "/" + PRJ + ".git;git --bare init") { + break + } + gi := DIR + "/.gitignore" + if !fileExists(gi) || yesno("overwrite existing .gitignore?", false) { + os.WriteFile(gi, []byte(gitignore), 0644) + } + os.RemoveAll(DIR + "/.git") + if !gitOK(DIR, "init") { + break + } + if !gitOK(DIR, "remote", "add", "origin", URL+"/"+PRJ+".git/") { + break + } + if !gitOK(DIR, "add", ".") { + break + } + if !gitOK(DIR, "commit", "-m", fmt.Sprintf("initial commit [%s,%s]", IP, USER)) { + break + } + gitOK(DIR, "push", "-u", "origin", "master") + } + + case "login": // open an interactive ssh session to the server + runInDir("", "ssh", "-p", cfg.GitPort, cfg.GitUser+"@"+cfg.GitHost) + + case "cd": // change the current project + if strings.HasPrefix(word(words, 1), ".") { + break // ignore filesystem-relative navigation + } + PRJ = "" + if isDir(BASE + "/" + word(words, 1)) { + PRJ = words[1] + } + + case "checkout": + if !requireRepo() { + break + } + gitOK(DIR, "checkout", word(words, 1)) + + case "clone": // clone a repository (or archive with -a) from the server + prj := PRJ + if w := word(words, 1); w != "" { + prj = w + } + path := "." + if opt["a"] { + path = "./archive" + } + found := 0 + lines, _ := sshOut("/bin/ls " + path) + for _, ln := range lines { + t := strings.TrimSpace(ln) + if opt["a"] { + if t == prj+".git.tar.gz" { + found++ + } + } else if t == prj+".git" { + found++ + } + } + if found != 1 { + errorln("repository not found, try 'list [-a]'") + break + } + if isDir(BASE + "/" + prj) { + if yesno("overwrite existing directory?", false) { + os.RemoveAll(BASE + "/" + prj) + } else { + break + } + } + if !opt["a"] { + if !gitOK(BASE, "clone", URL+"/"+prj+".git") { + break + } + } else { + if !sshOK("cd archive;tar xvzf " + prj + ".git.tar.gz") { + break + } + if !gitOK(BASE, "clone", URL+"/archive/"+prj+".git") { + break + } + sshOK("rm -rf archive/" + prj + ".git") + } + if isDir(BASE + "/" + prj) { + PRJ = prj + } + + case "cloneall": // clone every repository found on the server + path := "." + if opt["a"] { + path = "./archive" + } + lines, _ := sshOut("/bin/ls " + path) + for _, ln := range lines { + t := strings.TrimSpace(ln) + if m := gitDirRe.FindStringSubmatch(t); m != nil { + fmt.Printf("-%s-\n", m[1]) + gitOK(BASE, "clone", URL+"/"+m[1]+".git") + } + } + + case "open", "view": // open project in Xcode / editor + prj := PRJ + if w := word(words, 1); w != "" { + prj = w + } + d := BASE + "/" + prj + if !isDir(d) { + errorln("not found") + break + } + xws, xprj := "", "" + if entries, err := os.ReadDir(d); err == nil { + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".xcworkspace") { + xws = e.Name() + } + if strings.HasSuffix(e.Name(), ".xcodeproj") { + xprj = e.Name() + } + } + } + if xws != "" && xprj != "" { + xprj = "" // prefer the workspace + } + if xws != "" && isDir(d+"/"+xws) { + runInDir(d, "open", xws) + } + if xprj != "" && isDir(d+"/"+xprj) { + runInDir(d, "open", xprj) + } + if xws == "" && xprj == "" { + editor := cfg.Editor + if editor == "" { + editor = "coda" + } + runInDir(d, editor, d) + } + if words[0] == "open" { + PRJ = prj + } + + case "count": // count source lines in the project + countLines(DIR) + + case "tag": // manage tags + sub := word(words, 1) + switch { + case sub == "add" && word(words, 2) != "": + if gitOK(DIR, "tag", "-a", words[2], "-m", words[2]) { + gitOK(DIR, "push", "origin", words[2]) + } + case sub == "checkout" && word(words, 2) != "": + gitOK(DIR, "checkout", "tags/"+words[2]) + case sub == "delete" && word(words, 2) != "": + if gitOK(DIR, "tag", "-d", words[2]) { + gitOK(DIR, "push", "origin", ":refs/tags/"+words[2]) + } + case sub != "": + errorln("unkown tag subcommand") + default: + git(DIR, "tag", "-l", "--format=%(taggerdate:short): %(refname:short)") + } + + default: // unknown command — no longer forwarded to the shell + fmt.Println(col(cRed, "unknown command: "+words[0]) + + col(cGray, " (prefix with '!' to run a shell command)")) + } + + return true +} + +// --------------------------------------------------------------------------- +// repository log formatting +// --------------------------------------------------------------------------- + +// repolog prints the formatted log for "hash<2sp>epoch<2sp>subject" lines. +func repolog(lines []string) { + fmt.Print(formatLog(lines, time.Now())) +} + +// formatLog renders the log, picking the most compact date column that still +// disambiguates every entry. Returned as a string for testability. +func formatLog(lines []string, now time.Time) string { + days := [...]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} + months := [...]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} + + var full, short, tiny strings.Builder + n, ns := 0, 0 + + for _, ln := range lines { + if strings.TrimSpace(ln) == "" { + continue + } + parts := strings.SplitN(ln, " ", 3) + if len(parts) < 3 { + continue + } + hash := parts[0] + epoch, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) + if err != nil { + continue + } + subj := parts[2] + t := time.Unix(epoch, 0) + age := now.Sub(t) + wd := int(t.Weekday()) + + var z, zs, zss string + switch { + case age < 24*time.Hour: + z = fmt.Sprintf(" %02d:%02d", t.Hour(), t.Minute()) + zs = fmt.Sprintf(" %02d:%02d", t.Hour(), t.Minute()) + zss = fmt.Sprintf("%02d:%02d", t.Hour(), t.Minute()) + case age < 7*24*time.Hour: + z = fmt.Sprintf(" %s, %02d:%02d", days[wd], t.Hour(), t.Minute()) + zs = fmt.Sprintf("%s, %02d:%02d", days[wd], t.Hour(), t.Minute()) + zss = zs + ns++ + default: + z = fmt.Sprintf("%s, %02d %s %4d, %02d:%02d", + days[wd], t.Day(), months[t.Month()-1], t.Year(), t.Hour(), t.Minute()) + zs, zss = z, z + n++ + } + + fmt.Fprintf(&full, "%s %s %s\n", col(cPurple, hash), col(cYellow, z), subj) + fmt.Fprintf(&short, "%s %s %s\n", col(cPurple, hash), col(cYellow, zs), subj) + fmt.Fprintf(&tiny, "%s %s %s\n", col(cPurple, hash), col(cYellow, zss), subj) + } + + switch { + case n == 0 && ns == 0: + return tiny.String() + case n == 0 && ns > 0: + return short.String() + default: + return full.String() + } +} + +// --------------------------------------------------------------------------- +// line counting +// --------------------------------------------------------------------------- + +// countLines prints the line total over all source files in the project. +func countLines(dir string) { + total, files := countSourceLines(dir) + fmt.Printf("%d lines total in %d files in %s\n", total, files, PRJ) +} + +// countSourceLines walks dir and sums the lines of every source file, returning +// the line and file totals. A "source file" is any text (non-binary) file; +// hidden directories (.git, …) and hidden files are skipped. +func countSourceLines(dir string) (lines, files int) { + filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error { + if err != nil { + return nil + } + if fi.IsDir() { + if p != dir && strings.HasPrefix(fi.Name(), ".") { + return filepath.SkipDir + } + return nil + } + if strings.HasPrefix(fi.Name(), ".") || !isTextFile(p) { + return nil + } + lines += countFileLines(p) + files++ + return nil + }) + return lines, files +} + +func countFileLines(path string) int { + data, err := os.ReadFile(path) + if err != nil { + return 0 + } + return bytes.Count(data, []byte{'\n'}) +} + +func isTextFile(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + buf := make([]byte, 512) + n, _ := f.Read(buf) + return !bytes.Contains(buf[:n], []byte{0}) +} + +// --------------------------------------------------------------------------- +// misc command helpers +// --------------------------------------------------------------------------- + +func sanitizeComment(s string) string { + s = sanRe.ReplaceAllString(s, " ") + s = strings.TrimSpace(s) + s = wsRe.ReplaceAllString(s, "_") + return s +} + +// archiveStamp builds the DDMMYY.HHMM timestamp used for archive names. +func archiveStamp() string { + t := time.Now() + return fmt.Sprintf("%02d%02d%02d.%02d%02d", + t.Day(), int(t.Month()), t.Year()-2000, t.Hour(), t.Minute()) +} + +const gitignore = `.DS_Store +.AppleDouble +.LSOverride +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +` + +var helpItems = []struct{ cmd, desc string }{ + {"cd [project]", "change project"}, + {"open [project]", "open project"}, + {"init", "make new repository from current directory"}, + {"push [comment]", "push changes to git server"}, + {"pushremote [desc]", "mirror repo to a public server (gitea/github/gitlab) via API"}, + {"pull", "pull changes from git server"}, + {"fetch", "fetch changes from git server"}, + {"status [-a]", "short git status (-a: overview of all projects)"}, + {"overview", "status of all projects (dirty, ahead/behind)"}, + {"diff [args]", "show git diff"}, + {"edit [number]", "edit last [number] commits (default is 10)"}, + {"clone [-a] ", "clone repository from git server (-a for archive)"}, + {"cloneall", "clone all repository from git server"}, + {"checkout [git options]", "forward checkout to git"}, + {"log", "show log"}, + {"archive [comment]", "archive current repository"}, + {"list [-a] [pattern]", "list repositories on git server (-a for archive)"}, + {"show ", "show repository log on git server"}, + {"dist ", "cp changed files to other directory/repository"}, + {"login", "connect to git server"}, + {"count", "count lines in project"}, + {"tag", "show tags"}, + {"tag add ", "add tag"}, + {"tag checkout ", "checkout tag"}, + {"tag delete ", "delete tag"}, + {"alias [name [cmd]]", "list, show or define an alias ($1..$N, $* args)"}, + {"unalias ", "remove an alias"}, + {"rescan", "refresh cached server repository list"}, + {"!", "run in the shell"}, + {"quit", "exit mgsh"}, +} + +func help() { + fmt.Println() + fmt.Printf("%s v%s %s, builtin commands:\n\n", + col(cBold+cWhite, "mgsh (git shell)"), col(cYellow, VERSION), INFO) + for _, it := range helpItems { + fmt.Printf(" %s%s\n", col(cGreen, padRight(it.cmd, 25)), it.desc) + } + fmt.Println() +} diff --git a/completion.go b/completion.go new file mode 100644 index 0000000..8efb16c --- /dev/null +++ b/completion.go @@ -0,0 +1,166 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + + "github.com/chzyer/readline" +) + +// completer wires up Tab completion. Command names complete at the start of the +// line; cd/open/view complete local project names; clone/show complete +// repository names cached from the git server; checkout/tag complete branch and +// tag names; dist completes filesystem paths. +func completer() *readline.PrefixCompleter { + return readline.NewPrefixCompleter( + readline.PcItem("cd", readline.PcItemDynamic(dynLocalProjects)), + readline.PcItem("open", readline.PcItemDynamic(dynLocalProjects)), + readline.PcItem("view", readline.PcItemDynamic(dynLocalProjects)), + readline.PcItem("clone", + readline.PcItem("-a", readline.PcItemDynamic(dynServerArchives)), + readline.PcItemDynamic(dynServerRepos), + ), + readline.PcItem("cloneall"), + readline.PcItem("show", readline.PcItemDynamic(dynServerRepos)), + readline.PcItem("list", readline.PcItem("-a")), + readline.PcItem("push"), + readline.PcItem("pushremote"), + readline.PcItem("pull"), + readline.PcItem("fetch"), + readline.PcItem("status", readline.PcItem("-a")), + readline.PcItem("overview"), + readline.PcItem("diff"), + readline.PcItem("init"), + readline.PcItem("edit"), + readline.PcItem("checkout", readline.PcItemDynamic(dynCheckout)), + readline.PcItem("log"), + readline.PcItem("archive"), + readline.PcItem("dist", readline.PcItemDynamic(dynPaths)), + readline.PcItem("login"), + readline.PcItem("count"), + readline.PcItem("tag", + readline.PcItem("add"), + readline.PcItem("checkout", readline.PcItemDynamic(dynTags)), + readline.PcItem("delete", readline.PcItemDynamic(dynTags)), + ), + readline.PcItem("alias", readline.PcItemDynamic(dynAliasNames)), + readline.PcItem("unalias", readline.PcItemDynamic(dynAliasNames)), + readline.PcItem("rescan"), + readline.PcItem("help"), + readline.PcItem("quit"), + readline.PcItem("exit"), + ) +} + +// dynLocalProjects lists project directories under BASE (already name-sorted). +func dynLocalProjects(string) []string { + entries, err := os.ReadDir(BASE) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if e.IsDir() && !strings.HasPrefix(e.Name(), ".") { + out = append(out, e.Name()) + } + } + return out +} + +var ( + serverRepos []string + serverArchives []string + serverFetched bool +) + +// fetchServerRepos queries the server once and caches the repository/archive +// names for Tab completion. +func fetchServerRepos() { + if serverFetched { + return + } + serverFetched = true + if lines, err := sshOut("/bin/ls ."); err == nil { + for _, ln := range lines { + if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil { + serverRepos = append(serverRepos, m[1]) + } + } + } + if lines, err := sshOut("/bin/ls archive"); err == nil { + for _, ln := range lines { + t := strings.TrimSpace(ln) + if strings.HasSuffix(t, ".git.tar.gz") { + serverArchives = append(serverArchives, strings.TrimSuffix(t, ".git.tar.gz")) + } + } + } +} + +// rescanServer clears the cached server listing so the next completion (or use) +// re-fetches it. +func rescanServer() { + serverFetched = false + serverRepos = nil + serverArchives = nil +} + +func dynServerRepos(string) []string { fetchServerRepos(); return serverRepos } +func dynServerArchives(string) []string { fetchServerRepos(); return serverArchives } + +// dynBranches / dynTags list the active project's local branches / tags. +func dynBranches(string) []string { + if !isDir(DIR + "/.git") { + return nil + } + out, _ := gitCapture(DIR, "for-each-ref", "--format=%(refname:short)", "refs/heads") + return splitLines(out) +} + +func dynTags(string) []string { + if !isDir(DIR + "/.git") { + return nil + } + out, _ := gitCapture(DIR, "tag", "-l") + return splitLines(out) +} + +// dynCheckout offers both branches and tags for `checkout`. +func dynCheckout(line string) []string { + return append(dynBranches(line), dynTags(line)...) +} + +// dynPaths completes filesystem paths for the last token on the line. +func dynPaths(line string) []string { + partial := "" + if !strings.HasSuffix(line, " ") { + if f := strings.Fields(line); len(f) > 0 { + partial = f[len(f)-1] + } + } + dir := "." + if partial != "" { + if strings.HasSuffix(partial, "/") { + dir = partial + } else { + dir = filepath.Dir(partial) + } + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + continue + } + p := filepath.Join(dir, e.Name()) + if e.IsDir() { + p += "/" + } + out = append(out, p) + } + return out +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..fcdca43 --- /dev/null +++ b/config.go @@ -0,0 +1,177 @@ +package main + +import ( + "bufio" + "os" + "path/filepath" + "strings" +) + +// Config holds all externally configurable settings. It is read entirely from +// ~/.mgshrc and then overlaid with MGSH_* environment variables — there are no +// built-in defaults. Missing required settings are a fatal error (see +// missingRequired). +type Config struct { + Base string // project base directory + GitHost string // git server host + GitPort string // ssh port + GitUser string // ssh user + GitPath string // remote path holding the bare repos + GitKey string // ssh key name (informational) + GitName string // git user.name to set globally ("" = leave alone) + GitEmail string // git user.email to set globally ("" = leave alone) + PushDefault string // git push.default to set globally ("" = leave alone) + Editor string // editor/opener used as fallback by `open` ("" = coda) + RemoteURL string // public mirror server base URL (Gitea/GitHub/GitLab) + RemoteKey string // API token for the mirror server (used by `pushremote`) + RemoteType string // "gitea"|"github"|"gitlab" (auto-detected when empty) + RemoteVis string // visibility of created repos: "private" (default)|"public" + Mirror string // truthy -> `push` also mirrors via `pushremote` +} + +// requiredKeys lists the settings mgsh cannot run without. +var requiredKeys = []string{"base", "githost", "gitport", "gituser", "gitpath"} + +// missingRequired reports which required settings are still unset. mgsh refuses +// to start while this is non-empty. +func (c Config) missingRequired() []string { + have := map[string]string{ + "base": c.Base, "githost": c.GitHost, "gitport": c.GitPort, + "gituser": c.GitUser, "gitpath": c.GitPath, + } + var missing []string + for _, k := range requiredKeys { + if have[k] == "" { + missing = append(missing, k) + } + } + return missing +} + +// configFile returns the path of the user config file (~/.mgshrc). +func configFile() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".mgshrc") +} + +// loadConfig writes an empty ~/.mgshrc template on first run, then returns the +// configuration parsed from it, overlaid with MGSH_* environment variables. +// It applies no defaults; validation of required settings is the caller's job. +func loadConfig() Config { + path := configFile() + if !fileExists(path) { + writeConfigTemplate(path) + } + var c Config + if data, err := os.ReadFile(path); err == nil { + applyConfig(&c, parseConfig(string(data))) + } + applyEnv(&c) + return c +} + +// writeConfigTemplate creates a blank, annotated ~/.mgshrc for the user to fill +// in, migrating any aliases from the legacy ~/.mgsh_aliases file. It writes no +// real values — mgsh has no built-in configuration. +func writeConfigTemplate(path string) { + var b strings.Builder + b.WriteString("# mgsh configuration — fill in the required settings, then run mgsh again.\n") + b.WriteString("# Format: 'key = value' (or 'key: value'); '#' starts a comment.\n") + b.WriteString("# MGSH_* environment variables override these settings.\n\n") + b.WriteString("# --- required ---\n") + b.WriteString("base =\n") + b.WriteString("githost =\n") + b.WriteString("gitport =\n") + b.WriteString("gituser =\n") + b.WriteString("gitpath =\n\n") + b.WriteString("# --- optional ---\n") + b.WriteString("# gitkey =\n") + b.WriteString("# gitname = Your Name\n") + b.WriteString("# gitemail = you@example.com\n") + b.WriteString("# pushdefault = matching\n") + b.WriteString("# editor = code\n") + + legacy := readLegacyAliases() + if blk := aliasBlock(legacy); blk != "" { + b.WriteString("\n" + blk) + } + + if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil { + errorln("could not create " + path + ": " + err.Error()) + return + } + if len(legacy) > 0 { + os.Remove(legacyAliasFile()) // aliases now live in ~/.mgshrc + } +} + +// parseConfig reads simple `key = value` (or `key: value`) lines, ignoring +// blank lines, `#` comments and `alias` definitions. Keys are lower-cased; +// values are unquoted. +func parseConfig(s string) map[string]string { + m := map[string]string{} + sc := bufio.NewScanner(strings.NewReader(s)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if f := strings.Fields(line); len(f) > 0 && f[0] == "alias" { + continue // alias definitions are handled by the alias loader + } + i := strings.IndexAny(line, "=:") + if i < 0 { + continue + } + key := strings.ToLower(strings.TrimSpace(line[:i])) + val := strings.Trim(strings.TrimSpace(line[i+1:]), "\"'") + m[key] = val + } + return m +} + +func applyConfig(c *Config, m map[string]string) { + set := func(key string, dst *string) { + if v, ok := m[key]; ok && v != "" { + *dst = v + } + } + set("base", &c.Base) + set("githost", &c.GitHost) + set("gitport", &c.GitPort) + set("gituser", &c.GitUser) + set("gitpath", &c.GitPath) + set("gitkey", &c.GitKey) + set("gitname", &c.GitName) + set("gitemail", &c.GitEmail) + set("pushdefault", &c.PushDefault) + set("editor", &c.Editor) + set("remoteurl", &c.RemoteURL) + set("remotekey", &c.RemoteKey) + set("remotetype", &c.RemoteType) + set("remotevisibility", &c.RemoteVis) + set("mirror", &c.Mirror) +} + +func applyEnv(c *Config) { + env := func(key string, dst *string) { + if v := os.Getenv(key); v != "" { + *dst = v + } + } + env("MGSH_BASE", &c.Base) + env("MGSH_GITHOST", &c.GitHost) + env("MGSH_GITPORT", &c.GitPort) + env("MGSH_GITUSER", &c.GitUser) + env("MGSH_GITPATH", &c.GitPath) + env("MGSH_GITKEY", &c.GitKey) + env("MGSH_GITNAME", &c.GitName) + env("MGSH_GITEMAIL", &c.GitEmail) + env("MGSH_PUSHDEFAULT", &c.PushDefault) + env("MGSH_EDITOR", &c.Editor) + env("MGSH_REMOTEURL", &c.RemoteURL) + env("MGSH_REMOTEKEY", &c.RemoteKey) + env("MGSH_REMOTETYPE", &c.RemoteType) + env("MGSH_REMOTEVISIBILITY", &c.RemoteVis) + env("MGSH_MIRROR", &c.Mirror) +} diff --git a/git.go b/git.go new file mode 100644 index 0000000..8ae79ef --- /dev/null +++ b/git.go @@ -0,0 +1,72 @@ +package main + +import ( + "os" + "os/exec" + "strings" +) + +// runInDir runs a command with inherited stdio, optionally in dir. +func runInDir(dir, name string, args ...string) error { + c := exec.Command(name, args...) + if dir != "" { + c.Dir = dir + } + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() +} + +func git(dir string, args ...string) error { + return runInDir(dir, "git", args...) +} + +// gitOK runs git and, on failure, prints a red summary. Returns success. +func gitOK(dir string, args ...string) bool { + if err := git(dir, args...); err != nil { + errorln("git " + strings.Join(args, " ") + " failed") + return false + } + return true +} + +func gitCapture(dir string, args ...string) (string, error) { + c := exec.Command("git", args...) + if dir != "" { + c.Dir = dir + } + out, err := c.Output() + return string(out), err +} + +// ssh runs a single remote command over ssh with inherited stdio. +func ssh(remote string) error { + return runInDir("", "ssh", "-p", cfg.GitPort, cfg.GitUser+"@"+cfg.GitHost, remote) +} + +// sshOK runs a remote command and, on failure, prints a red summary. +func sshOK(remote string) bool { + if err := ssh(remote); err != nil { + errorln("ssh failed: " + remote) + return false + } + return true +} + +// sshOut runs a remote command and returns its stdout split into lines. +func sshOut(remote string) ([]string, error) { + c := exec.Command("ssh", "-p", cfg.GitPort, cfg.GitUser+"@"+cfg.GitHost, remote) + c.Stderr = os.Stderr + out, err := c.Output() + lines := strings.Split(string(out), "\n") + for len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines, err +} + +// forwardShell runs a line via /bin/sh -c in the active project directory. +func forwardShell(line string) { + runInDir(DIR, "/bin/sh", "-c", line) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f0e1e31 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module mgsh + +go 1.21 + +require github.com/chzyer/readline v1.5.1 + +require golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b99eb42 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/input.go b/input.go new file mode 100644 index 0000000..8ab8398 --- /dev/null +++ b/input.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// yesno asks a y/n question with a default. Reads a single keypress. +func yesno(prompt string, def bool) bool { + suffix := " y/N ? " + if def { + suffix = " Y/n ? " + } + ans := strings.ToLower(strings.TrimSpace(getkey(prompt + suffix))) + if ans == "" { + return def + } + return ans == "y" || ans == "yes" +} + +// getkey reads a single keypress from the terminal without echo. It reads a +// single byte directly from stdin; readline is not reading at this point (we +// are inside command execution), so there is no reader to desync with. +func getkey(prompt string) string { + fmt.Print(prompt) + stty("-icanon", "-echo") + var buf [1]byte + n, err := os.Stdin.Read(buf[:]) + stty("icanon", "echo") + key := "" + if err == nil && n > 0 { + key = strings.Trim(string(buf[:n]), "\r\n\t") + } + fmt.Println(key) + return key +} + +func stty(args ...string) { + c := exec.Command("stty", args...) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Run() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..028b482 --- /dev/null +++ b/main.go @@ -0,0 +1,209 @@ +// 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") + } + } +} diff --git a/mgsh_test.go b/mgsh_test.go new file mode 100644 index 0000000..494c955 --- /dev/null +++ b/mgsh_test.go @@ -0,0 +1,423 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func TestSanitizeComment(t *testing.T) { + cases := []struct{ in, want string }{ + {"", ""}, + {"simple comment", "simple_comment"}, + {"it's a test", "it_s_a_test"}, + {"a/b:c;d", "a_b_c_d"}, + {"added -v flag", "added_v_flag"}, + {" padded spaces ", "padded_spaces"}, + {"weird<>|?chars", "weird_chars"}, + } + for _, c := range cases { + if got := sanitizeComment(c.in); got != c.want { + t.Errorf("sanitizeComment(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestArchiveStampFormat(t *testing.T) { + s := archiveStamp() + // expect DDMMYY.HHMM => 6 digits, dot, 4 digits + if len(s) != 11 || s[6] != '.' { + t.Fatalf("archiveStamp() = %q, want DDMMYY.HHMM shape", s) + } + for i, r := range s { + if i == 6 { + continue + } + if r < '0' || r > '9' { + t.Fatalf("archiveStamp() = %q has non-digit at %d", s, i) + } + } +} + +func TestFormatLogNoColor(t *testing.T) { + useColor = false + // fixed reference time: 2020-06-15 12:00:00 local + now := time.Date(2020, 6, 15, 12, 0, 0, 0, time.Local) + old := time.Date(2019, 10, 17, 16, 9, 0, 0, time.Local).Unix() + line := "f9e76ff " + strconv.FormatInt(old, 10) + " initial commit" + out := formatLog([]string{line}, now) + if !strings.Contains(out, "f9e76ff") || !strings.Contains(out, "initial commit") { + t.Fatalf("formatLog missing hash/subject: %q", out) + } + // older than a week -> full date form contains year and weekday + if !strings.Contains(out, "2019") || !strings.Contains(out, "Thu") { + t.Fatalf("formatLog full date form expected, got %q", out) + } +} + +func TestFormatLogRecentCompact(t *testing.T) { + useColor = false + now := time.Date(2020, 6, 15, 12, 0, 0, 0, time.Local) + recent := now.Add(-2 * time.Hour).Unix() + out := formatLog([]string{"abc123 " + strconv.FormatInt(recent, 10) + " recent work"}, now) + // within a day -> tiny form: only HH:MM, no year, no weekday + if strings.Contains(out, "2020") { + t.Fatalf("recent entry should use compact time form, got %q", out) + } + if !strings.Contains(out, "recent work") { + t.Fatalf("missing subject, got %q", out) + } +} + +func TestColorRepoLine(t *testing.T) { + useColor = false + in := "Sep 28 2016 Betaflight3.0.0" + if got := colorRepoLine(in); got != in { + t.Errorf("colorRepoLine with color off changed input: %q", got) + } + + useColor = true + got := colorRepoLine(in) + if !strings.Contains(got, "Betaflight3.0.0") || !strings.Contains(got, cGreen) || !strings.Contains(got, cYellow) { + t.Errorf("colorRepoLine did not color parts: %q", got) + } + useColor = false +} + +func TestParseConfig(t *testing.T) { + rc := ` +# comment line +githost = 10.0.0.1 +GitPort: 22 +gituser = "deploy" +editor = 'code' +ignored line without separator +base=/tmp/src +` + m := parseConfig(rc) + checks := map[string]string{ + "githost": "10.0.0.1", + "gitport": "22", + "gituser": "deploy", + "editor": "code", + "base": "/tmp/src", + } + for k, want := range checks { + if m[k] != want { + t.Errorf("parseConfig[%q] = %q, want %q", k, m[k], want) + } + } + if _, ok := m["ignored line without separator"]; ok { + t.Errorf("line without separator should be ignored") + } +} + +func TestApplyConfig(t *testing.T) { + c := Config{GitName: "Original Name"} + applyConfig(&c, map[string]string{ + "githost": "example.com", + "gitport": "2200", + "gitname": "", // empty must not override an existing value + }) + if c.GitHost != "example.com" { + t.Errorf("GitHost = %q, want example.com", c.GitHost) + } + if c.GitPort != "2200" { + t.Errorf("GitPort = %q, want 2200", c.GitPort) + } + if c.GitName != "Original Name" { + t.Errorf("empty value must not override GitName, got %q", c.GitName) + } +} + +func TestMissingRequired(t *testing.T) { + full := Config{Base: "/b", GitHost: "h", GitPort: "22", GitUser: "u", GitPath: "/p"} + if m := full.missingRequired(); len(m) != 0 { + t.Errorf("complete config reported missing: %v", m) + } + partial := Config{Base: "/b", GitPort: "22"} + got := strings.Join(partial.missingRequired(), ",") + if got != "githost,gituser,gitpath" { + t.Errorf("missingRequired = %q, want githost,gituser,gitpath", got) + } +} + +func TestSplitLines(t *testing.T) { + if got := splitLines(""); got != nil { + t.Errorf("splitLines(\"\") = %v, want nil", got) + } + got := splitLines("a\nb\nc\n") + if len(got) != 3 || got[0] != "a" || got[2] != "c" { + t.Errorf("splitLines = %v", got) + } +} + +func TestWord(t *testing.T) { + ws := []string{"a", "b"} + if word(ws, 0) != "a" || word(ws, 1) != "b" || word(ws, 2) != "" { + t.Errorf("word indexing wrong") + } +} + +func TestUnquote(t *testing.T) { + cases := []struct{ in, want string }{ + {"'echo $1'", "echo $1"}, + {`"echo $1"`, "echo $1"}, + {"echo $1", "echo $1"}, + {"'unbalanced", "'unbalanced"}, + {"''", ""}, + {"'", "'"}, + } + for _, c := range cases { + if got := unquote(c.in); got != c.want { + t.Errorf("unquote(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestConfigTemplateGenerated(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + for _, e := range []string{"MGSH_BASE", "MGSH_GITHOST", "MGSH_GITPORT", "MGSH_GITUSER", "MGSH_GITPATH"} { + t.Setenv(e, "") // ensure env can't satisfy the requirements + } + rc := filepath.Join(home, ".mgshrc") + + // no ~/.mgshrc yet -> loadConfig writes a blank template (no real values) + c := loadConfig() + if !fileExists(rc) { + t.Fatalf("loadConfig did not generate %s", rc) + } + if m := c.missingRequired(); len(m) != len(requiredKeys) { + t.Fatalf("blank template should leave all required unset, missing=%v", m) + } + if s := readFile(t, rc); !strings.Contains(s, "base") || !strings.Contains(s, "githost") { + t.Fatalf("template missing key hints:\n%s", s) + } +} + +func TestConfigLoadAndAliasRoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + rc := filepath.Join(home, ".mgshrc") + os.WriteFile(rc, []byte( + "base = "+home+"\ngithost = h.example\ngitport = 22\ngituser = git\ngitpath = /home/git\n"), 0644) + + c := loadConfig() + if m := c.missingRequired(); len(m) != 0 { + t.Fatalf("configured file still reports missing: %v", m) + } + if c.GitHost != "h.example" { + t.Fatalf("GitHost = %q, want h.example", c.GitHost) + } + + // defining an alias persists it into ~/.mgshrc without losing config lines + aliases = map[string]string{"co": "checkout $1"} + saveAliases() + s := readFile(t, rc) + if !strings.Contains(s, "alias co 'checkout $1'") { + t.Fatalf("alias not written to rc:\n%s", s) + } + if !strings.Contains(s, "githost = h.example") { + t.Fatalf("saveAliases clobbered config lines:\n%s", s) + } + + // and it reloads from the same file + aliases = map[string]string{} + loadAliases() + if aliases["co"] != "checkout $1" { + t.Fatalf("alias did not round-trip, got %q", aliases["co"]) + } +} + +func TestLegacyAliasMigration(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + legacy := filepath.Join(home, ".mgsh_aliases") + if err := os.WriteFile(legacy, []byte("# header\nls\t!lsd -la\n"), 0644); err != nil { + t.Fatal(err) + } + + loadConfig() // generates ~/.mgshrc and migrates the legacy alias + rc := filepath.Join(home, ".mgshrc") + if s := readFile(t, rc); !strings.Contains(s, "alias ls '!lsd -la'") { + t.Fatalf("legacy alias not migrated into rc:\n%s", s) + } + if fileExists(legacy) { + t.Fatalf("legacy alias file should be removed after migration") + } + + aliases = map[string]string{} + loadAliases() + if aliases["ls"] != "!lsd -la" { + t.Fatalf("migrated alias not loaded, got %q", aliases["ls"]) + } +} + +func readFile(t *testing.T, p string) string { + t.Helper() + data, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestTruthy(t *testing.T) { + on := []string{"1", "true", "TRUE", "yes", "On", " true "} + off := []string{"", "0", "false", "no", "off", "nope"} + for _, s := range on { + if !truthy(s) { + t.Errorf("truthy(%q) = false, want true", s) + } + } + for _, s := range off { + if truthy(s) { + t.Errorf("truthy(%q) = true, want false", s) + } + } +} + +func TestFormatProjStatus(t *testing.T) { + useColor = false + defer func() { useColor = false }() + cases := []struct { + s projStatus + contains []string + absent []string + }{ + {projStatus{name: "a", branch: "master", dirty: true, hasUpstream: true, ahead: 2}, + []string{"a", "*", "↑2"}, []string{"↓", "(master)"}}, + {projStatus{name: "b", branch: "main", hasUpstream: true, behind: 3}, + []string{"b", "↓3"}, []string{"*", "✓", "(main)"}}, + {projStatus{name: "c", branch: "master", hasUpstream: true}, + []string{"c", "✓"}, []string{"*", "↑", "↓"}}, + {projStatus{name: "d", branch: "feature", dirty: true}, + []string{"d", "*", "(feature)"}, nil}, + {projStatus{name: "e", branch: "master"}, // clean, no upstream + []string{"e", "no upstream"}, []string{"*"}}, + } + for _, c := range cases { + got := formatProjStatus(c.s, 8) + for _, sub := range c.contains { + if !strings.Contains(got, sub) { + t.Errorf("formatProjStatus(%+v) = %q, missing %q", c.s, got, sub) + } + } + for _, sub := range c.absent { + if strings.Contains(got, sub) { + t.Errorf("formatProjStatus(%+v) = %q, should not contain %q", c.s, got, sub) + } + } + } +} + +func TestDetectRemoteKind(t *testing.T) { + cases := []struct { + url, override string + want remoteKind + }{ + {"https://git.fhi.mpg.de", "", kindGitea}, + {"https://github.com", "", kindGitHub}, + {"https://api.github.com", "", kindGitHub}, + {"https://gitlab.com", "", kindGitLab}, + {"https://gitlab.example.org", "", kindGitLab}, + {"https://git.fhi.mpg.de", "github", kindGitHub}, // override wins + {"https://github.com", "gitlab", kindGitLab}, // override wins + {"https://anything", "", kindGitea}, // default + } + for _, c := range cases { + if got := detectRemoteKind(c.url, c.override); got != c.want { + t.Errorf("detectRemoteKind(%q,%q) = %d, want %d", c.url, c.override, got, c.want) + } + } +} + +func TestRemoteAPIEndpoints(t *testing.T) { + cases := []struct { + url, typ string + root, web, hdrKey string + }{ + {"https://git.fhi.mpg.de/", "", "https://git.fhi.mpg.de/api/v1", "https://git.fhi.mpg.de/mike/mgsh.git", "Authorization"}, + {"https://github.com", "", "https://api.github.com", "https://github.com/mike/mgsh.git", "Authorization"}, + {"https://gitlab.com", "", "https://gitlab.com/api/v4", "https://gitlab.com/mike/mgsh.git", "PRIVATE-TOKEN"}, + } + for _, c := range cases { + api := newRemoteAPI(c.url, "tok", c.typ) + if got := api.apiRoot(); got != c.root { + t.Errorf("apiRoot(%q) = %q, want %q", c.url, got, c.root) + } + if got := api.repoWebURL("mike", "mgsh"); got != c.web { + t.Errorf("repoWebURL(%q) = %q, want %q", c.url, got, c.web) + } + if k, _ := api.authHeader(); k != c.hdrKey { + t.Errorf("authHeader(%q) key = %q, want %q", c.url, k, c.hdrKey) + } + } +} + +func TestFirstLine(t *testing.T) { + if got := firstLine([]byte(" hello\nworld ")); got != "hello" { + t.Errorf("firstLine multiline = %q, want hello", got) + } + long := strings.Repeat("x", 300) + if got := firstLine([]byte(long)); len(got) != 200 { + t.Errorf("firstLine did not truncate, len=%d", len(got)) + } +} + +func TestCountSourceLines(t *testing.T) { + dir := t.TempDir() + write := func(rel string, data []byte) { + p := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, data, 0644); err != nil { + t.Fatal(err) + } + } + // counted: any text file, regardless of extension, in any visible subdir + write("main.go", []byte("a\nb\nc\n")) // 3 + write("Makefile", []byte("all:\n\techo hi\n")) // 2 (no extension) + write("sub/util.py", []byte("x\ny\n")) // 2 + write("bin/script", []byte("#!/bin/sh\nls\n")) // 2 + // skipped: hidden file, file in hidden dir, and a binary file + write(".gitignore", []byte("node_modules\n")) + write(".git/config", []byte("[core]\n\trepo\n")) + write("logo.png", []byte{0x89, 'P', 'N', 'G', 0x00, 0x0a, 0x00}) + + lines, files := countSourceLines(dir) + if files != 4 { + t.Errorf("countSourceLines files = %d, want 4", files) + } + if lines != 9 { + t.Errorf("countSourceLines lines = %d, want 9", lines) + } +} + +func TestExpandAlias(t *testing.T) { + cases := []struct { + body string + args []string + want string + }{ + {"checkout $1", []string{"main"}, "checkout main"}, + {"!echo $1", []string{"hello"}, "!echo hello"}, + {"push $*", []string{"fixed", "bug"}, "push fixed bug"}, + {"push $@", []string{"a", "b"}, "push a b"}, + {"echo $1 $2", []string{"a"}, "echo a"}, // missing $2 -> empty + {"status", []string{"x"}, "status x"}, // no placeholder -> append args + {"status", nil, "status"}, // no placeholder, no args + {"log", []string{}, "log"}, // empty args slice + {"diff $1", nil, "diff"}, // placeholder with no arg -> empty + } + for _, c := range cases { + if got := expandAlias(c.body, c.args); got != c.want { + t.Errorf("expandAlias(%q, %v) = %q, want %q", c.body, c.args, got, c.want) + } + } +} diff --git a/mgshrc.example b/mgshrc.example new file mode 100644 index 0000000..71ad053 --- /dev/null +++ b/mgshrc.example @@ -0,0 +1,34 @@ +# Example mgsh configuration. mgsh writes a blank ~/.mgshrc on first run; fill +# in the required settings (mgsh refuses to start until they are set). This file +# is just an annotated reference. +# +# Simple "key = value" (or "key: value") lines; '#' starts a comment. ~/.mgshrc +# is the sole source of configuration (there are no built-in defaults); MGSH_* +# environment variables override individual settings. + +# --- required --- +base = /Users/me/src +githost = git.example.com +gitport = 22 +gituser = git +gitpath = /home/git + +# --- optional --- +# gitkey = mgit_rsa +# gitname = Your Name +# gitemail = you@example.com +# pushdefault = matching +# editor = code + +# --- pushremote: mirror to a public server (gitea/github/gitlab) via its API --- +# remoteurl = https://git.example.com +# remotekey = +# remotetype = gitea # optional; auto-detected from remoteurl +# remotevisibility = private # visibility of created repos (default private) +# mirror = true # `push` also mirrors via pushremote + +# Aliases live in the same file (managed by the `alias` command). The expansion +# is a mgsh command line; $1..$N and $*/$@ expand arguments, and a shell command +# needs a leading '!'. +alias co 'checkout $1' +alias ec '!echo $1' diff --git a/overview.go b/overview.go new file mode 100644 index 0000000..a5609f3 --- /dev/null +++ b/overview.go @@ -0,0 +1,108 @@ +package main + +// overview.go — the `overview` command (also reachable as `status -a`): a +// one-line-per-project summary of every git project under BASE, showing the +// dirty state and how far each branch is ahead/behind its upstream. + +import ( + "fmt" + "os" + "strings" +) + +// projStatus is the collected state of one project for the overview. +type projStatus struct { + name string + branch string + dirty bool + ahead, behind int + hasUpstream bool +} + +// overviewAll prints a status summary for all git projects under BASE. +func overviewAll() { + entries, err := os.ReadDir(BASE) + if err != nil { + errorln("cannot read " + BASE + ": " + err.Error()) + return + } + + var rows []projStatus + width := 0 + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + dir := BASE + "/" + e.Name() + if !isDir(dir + "/.git") { + continue + } + rows = append(rows, projectStatus(e.Name(), dir)) + if len(e.Name()) > width { + width = len(e.Name()) + } + } + + if len(rows) == 0 { + fmt.Println(col(cGray, "no git projects under "+BASE)) + return + } + + dirtyN, syncN := 0, 0 + for _, r := range rows { + fmt.Println(formatProjStatus(r, width)) + if r.dirty { + dirtyN++ + } + if r.hasUpstream && r.ahead == 0 && r.behind == 0 && !r.dirty { + syncN++ + } + } + fmt.Printf("%s\n", col(cGray, fmt.Sprintf("%d projects · %d dirty · %d in sync", len(rows), dirtyN, syncN))) +} + +// projectStatus gathers the git state of a single project directory. +func projectStatus(name, dir string) projStatus { + s := projStatus{name: name, branch: "-"} + if out, err := gitCapture(dir, "rev-parse", "--abbrev-ref", "HEAD"); err == nil { + s.branch = strings.TrimSpace(out) + } + if out, err := gitCapture(dir, "status", "--porcelain"); err == nil && strings.TrimSpace(out) != "" { + s.dirty = true + } + // left/right counts against the upstream: "\t" + if out, err := gitCapture(dir, "rev-list", "--left-right", "--count", "@{upstream}...HEAD"); err == nil { + if _, e := fmt.Sscanf(strings.TrimSpace(out), "%d\t%d", &s.behind, &s.ahead); e == nil { + s.hasUpstream = true + } + } + return s +} + +// formatProjStatus renders one aligned overview row. +func formatProjStatus(s projStatus, width int) string { + var marks []string + if s.dirty { + marks = append(marks, col(cYellow, "*")) + } + if s.ahead > 0 { + marks = append(marks, col(cGreen, fmt.Sprintf("↑%d", s.ahead))) + } + if s.behind > 0 { + marks = append(marks, col(cRed, fmt.Sprintf("↓%d", s.behind))) + } + state := strings.Join(marks, " ") + if state == "" { + if s.hasUpstream { + state = col(cGreen, "✓") + } else { + state = col(cGray, "✓ (no upstream)") + } + } + + line := " " + col(cGreen, padRight(s.name, width+2)) + state + if s.branch != "master" && s.branch != "main" && s.branch != "-" { + line += col(cGray, " ("+s.branch+")") + } + return line +} diff --git a/prompt.go b/prompt.go new file mode 100644 index 0000000..bd720b8 --- /dev/null +++ b/prompt.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" +) + +// banner is the colored startup line: name/"v"/info white-bold, version yellow, +// path green. +func banner() string { + if useColor { + wb := cBold + cWhite + return fmt.Sprintf("%smgsh%s %sv%s%s%s%s %s%s,%s %s%s%s", + wb, cReset, // mgsh + wb, cReset, cYellow, VERSION, cReset, // v (white) + version (yellow) + wb, INFO, cReset, // mwx'2026, (white) + cGreen, BASE, cReset) // path (green) + } + return fmt.Sprintf("mgsh v%s %s, %s", VERSION, INFO, BASE) +} + +func promptString() string { + if useColor { + return coloredPrompt() + } + return plainPrompt() +} + +func plainPrompt() string { + base := filepath.Base(BASE) + if PRJ != "" && isDir(BASE+"/"+PRJ) { + p := base + "/" + PRJ + BPLSTATE + if BRANCH != "" { + p += " (" + BRANCH + if DIRTY { + p += "*" + } + p += ")" + } + return p + " > " + } + return base + " > " +} + +func coloredPrompt() string { + var b strings.Builder + b.WriteString(cBold + cPurple + "< " + cReset) + b.WriteString(cCyan + filepath.Base(BASE) + cReset) + if PRJ != "" && isDir(BASE+"/"+PRJ) { + b.WriteString(cBold + cWhite + "/" + cReset + cBold + cGreen + PRJ + cReset) + if BPLSTATE != "" { + b.WriteString(cRed + BPLSTATE + cReset) + } + if BRANCH != "" { + b.WriteString(cGray + " (" + cReset + cCyan + BRANCH + cReset) + if DIRTY { + b.WriteString(cRed + "*" + cReset) + } + b.WriteString(cGray + ")" + cReset) + } + } + b.WriteString(" " + cBold + cPurple + ">" + cReset + " ") + return b.String() +} diff --git a/remote.go b/remote.go new file mode 100644 index 0000000..d81958a --- /dev/null +++ b/remote.go @@ -0,0 +1,305 @@ +package main + +// remote.go — the `pushremote` command: mirror the active project to a public +// git hosting server (Gitea, GitHub or GitLab), creating the repository via the +// server's REST API when it does not exist yet. +// +// Configuration (in ~/.mgshrc or MGSH_* env): +// +// remoteurl = https://git.example.com base URL of the server +// remotekey = personal access token +// remotetype = gitea|github|gitlab optional; auto-detected from the URL +// +// The token is used for the API calls and, via an HTTP Basic auth header, for +// the git push. It is never written into the repository's git config. + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "strings" + "time" +) + +type remoteKind int + +const ( + kindGitea remoteKind = iota + kindGitHub + kindGitLab +) + +// detectRemoteKind picks the provider from an explicit override or the URL. +func detectRemoteKind(rawurl, override string) remoteKind { + switch strings.ToLower(strings.TrimSpace(override)) { + case "github": + return kindGitHub + case "gitlab": + return kindGitLab + case "gitea": + return kindGitea + } + u := strings.ToLower(rawurl) + switch { + case strings.Contains(u, "github.com") || strings.Contains(u, "api.github.com"): + return kindGitHub + case strings.Contains(u, "gitlab"): + return kindGitLab + default: + return kindGitea + } +} + +// remoteAPI talks to one provider's REST API. +type remoteAPI struct { + kind remoteKind + url string // base URL, trailing slash trimmed + key string + http *http.Client +} + +func newRemoteAPI(cfgURL, key, typ string) *remoteAPI { + return &remoteAPI{ + kind: detectRemoteKind(cfgURL, typ), + url: strings.TrimRight(strings.TrimSpace(cfgURL), "/"), + key: strings.TrimSpace(key), + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// apiRoot returns the REST API root for the provider. +func (r *remoteAPI) apiRoot() string { + switch r.kind { + case kindGitHub: + if r.url == "" || strings.Contains(r.url, "github.com") { + return "https://api.github.com" + } + return r.url + "/api/v3" // GitHub Enterprise + case kindGitLab: + return r.url + "/api/v4" + default: // Gitea + return r.url + "/api/v1" + } +} + +// authHeader returns the header name/value used to authenticate API calls. +func (r *remoteAPI) authHeader() (string, string) { + switch r.kind { + case kindGitLab: + return "PRIVATE-TOKEN", r.key + case kindGitHub: + return "Authorization", "Bearer " + r.key + default: // Gitea + return "Authorization", "token " + r.key + } +} + +func (r *remoteAPI) do(method, endpoint string, body any) (int, []byte, error) { + var rdr io.Reader + if body != nil { + b, _ := json.Marshal(body) + rdr = bytes.NewReader(b) + } + req, err := http.NewRequest(method, endpoint, rdr) + if err != nil { + return 0, nil, err + } + hk, hv := r.authHeader() + req.Header.Set(hk, hv) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := r.http.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return resp.StatusCode, data, nil +} + +// authUser returns the login/username of the token owner. +func (r *remoteAPI) authUser() (string, error) { + code, data, err := r.do("GET", r.apiRoot()+"/user", nil) + if err != nil { + return "", err + } + if code != 200 { + return "", fmt.Errorf("authentication failed (HTTP %d): %s", code, firstLine(data)) + } + var u struct { + Login string `json:"login"` // Gitea, GitHub + Username string `json:"username"` // GitLab + } + json.Unmarshal(data, &u) + if u.Login != "" { + return u.Login, nil + } + if u.Username != "" { + return u.Username, nil + } + return "", fmt.Errorf("could not determine remote user") +} + +// repoExists reports whether owner/repo already exists on the server. +func (r *remoteAPI) repoExists(owner, repo string) (bool, error) { + var ep string + if r.kind == kindGitLab { + ep = r.apiRoot() + "/projects/" + url.PathEscape(owner+"/"+repo) + } else { + ep = r.apiRoot() + "/repos/" + owner + "/" + repo + } + code, data, err := r.do("GET", ep, nil) + if err != nil { + return false, err + } + switch code { + case 200: + return true, nil + case 404: + return false, nil + default: + return false, fmt.Errorf("checking repository failed (HTTP %d): %s", code, firstLine(data)) + } +} + +// createRepo creates owner/repo on the server with the given visibility and +// (optional) description. +func (r *remoteAPI) createRepo(repo, description string, private bool) error { + var ep string + var body map[string]any + if r.kind == kindGitLab { + vis := "public" + if private { + vis = "private" + } + ep = r.apiRoot() + "/projects" + body = map[string]any{"name": repo, "visibility": vis} + } else { // Gitea + GitHub + ep = r.apiRoot() + "/user/repos" + body = map[string]any{"name": repo, "private": private} + } + if description != "" { + body["description"] = description + } + code, data, err := r.do("POST", ep, body) + if err != nil { + return err + } + if code != 200 && code != 201 { + return fmt.Errorf("creating repository failed (HTTP %d): %s", code, firstLine(data)) + } + return nil +} + +// repoWebURL returns the https clone/push URL (without credentials). +func (r *remoteAPI) repoWebURL(owner, repo string) string { + base := r.url + if r.kind == kindGitHub && (base == "" || strings.Contains(base, "api.github.com")) { + base = "https://github.com" + } + return base + "/" + owner + "/" + repo + ".git" +} + +// handlePushRemote implements the `pushremote [description]` command. The +// description, if given, is set on the repository when it is created. +func handlePushRemote(description string) { + if !requireRepo() { + return + } + if cfg.RemoteURL == "" || cfg.RemoteKey == "" { + errorln("pushremote needs 'remoteurl' and 'remotekey' in ~/.mgshrc") + return + } + repo := PRJ + if repo == "" { + repo = REPO + } + if repo == "" { + errorln("cannot determine repository name") + return + } + + private := !strings.EqualFold(strings.TrimSpace(cfg.RemoteVis), "public") + + api := newRemoteAPI(cfg.RemoteURL, cfg.RemoteKey, cfg.RemoteType) + + owner, err := api.authUser() + if err != nil { + errorln(err.Error()) + return + } + fmt.Printf("%s %s (as %s)\n", col(cGray, "remote"), col(cCyan, api.url), col(cGreen, owner)) + + exists, err := api.repoExists(owner, repo) + if err != nil { + errorln(err.Error()) + return + } + if exists { + fmt.Printf("repository %s exists\n", col(cGreen, owner+"/"+repo)) + } else { + vis := "private" + if !private { + vis = "public" + } + fmt.Printf("creating %s repository %s ...\n", vis, col(cGreen, owner+"/"+repo)) + if err := api.createRepo(repo, description, private); err != nil { + errorln(err.Error()) + return + } + } + + // keep a credential-free remote named "public" for convenience + web := api.repoWebURL(owner, repo) + if _, err := gitCapture(DIR, "remote", "get-url", "public"); err == nil { + gitOK(DIR, "remote", "set-url", "public", web) + } else { + gitOK(DIR, "remote", "add", "public", web) + } + + // authenticate the push with a one-shot Basic auth header so the token is + // never persisted in the repository's git config + header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(owner+":"+api.key)) + if !gitPushHeader(DIR, "public", header, "--all") { + return + } + gitPushHeader(DIR, "public", header, "--tags") + fmt.Println(col(cGreen, "pushed to ") + col(cCyan, web)) +} + +// gitPushHeader runs `git push ` with an extra HTTP auth +// header, disabling interactive credential prompts. +func gitPushHeader(dir, remote, header string, args ...string) bool { + full := append([]string{"-c", "http.extraHeader=" + header, "push", remote}, args...) + c := exec.Command("git", full...) + c.Dir = dir + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + if err := c.Run(); err != nil { + errorln("git push " + remote + " " + strings.Join(args, " ") + " failed") + return false + } + return true +} + +// firstLine trims an API response body to a short single-line summary. +func firstLine(b []byte) string { + s := strings.TrimSpace(string(b)) + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[:i] + } + if len(s) > 200 { + s = s[:200] + } + return s +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..13feedf --- /dev/null +++ b/util.go @@ -0,0 +1,94 @@ +package main + +import ( + "bytes" + "net" + "os" + "path/filepath" + "strings" +) + +func word(words []string, i int) string { + if i < len(words) { + return words[i] + } + return "" +} + +// splitLines splits s on newlines, dropping a trailing newline and returning +// nil for empty input. +func splitLines(s string) []string { + s = strings.TrimRight(s, "\n") + if s == "" { + return nil + } + return strings.Split(s, "\n") +} + +func isDir(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.IsDir() +} + +// truthy reports whether a config string means "on" (1/true/yes/on). +func truthy(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "1", "true", "yes", "on": + return true + } + return false +} + +func fileExists(p string) bool { + fi, err := os.Stat(p) + return err == nil && !fi.IsDir() +} + +func filesEqual(a, b string) bool { + da, ea := os.ReadFile(a) + db, eb := os.ReadFile(b) + if ea != nil || eb != nil { + return false + } + return bytes.Equal(da, db) +} + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + mode := os.FileMode(0644) + if fi, err := os.Stat(src); err == nil { + mode = fi.Mode() + } + return os.WriteFile(dst, data, mode) +} + +func resolveIP() string { + h, err := os.Hostname() + if err != nil { + return "" + } + addrs, err := net.LookupHost(h) + if err != nil || len(addrs) == 0 { + return "" + } + for _, a := range addrs { + if ip := net.ParseIP(a); ip != nil && ip.To4() != nil { + return a + } + } + return addrs[0] +} + +func shortHostname() string { + h, _ := os.Hostname() + if i := strings.IndexByte(h, '.'); i >= 0 { + h = h[:i] + } + return h +} diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..30b26df --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +4.0.11