commit 5562055695fce4797457792fcac4ae11468a7420 Author: Michael Wesemann Date: Sun Jul 26 06:38:01 2026 +0200 initial commit [141.14.129.234,mike] 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