initial commit [141.14.129.234,mike]

This commit is contained in:
2026-07-26 06:38:01 +02:00
commit 5562055695
20 changed files with 2898 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.DS_Store
.AppleDouble
.LSOverride
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
mgsh
+156
View File
@@ -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] <repo>` | clone a repository (or archive) from the server |
| `list [-a] [pattern]` | list repositories on the server |
| `show <repo>` | 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 <name>` | remove a command alias |
| `rescan` | refresh the cached server repository list |
| `!<command>` | run `<command>` in the shell |
### Aliases
`alias <name> '<command>'` 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 <name>` shows one, and
`unalias <name>` 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 = <personal-access-token> # 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 <description>`) 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.
+289
View File
@@ -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 <name> '<expansion>'"
// 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 <name> '<body>'" 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 ("name<whitespace>body"
// 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 <name> show a single alias
// alias <name> <cmd> define an alias (surrounding quotes on <cmd> 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 <name>")
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() }
Executable
+23
View File
@@ -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"
+59
View File
@@ -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)
}
+646
View File
@@ -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] <repository>", "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 <repository>", "show repository log on git server"},
{"dist <path>", "cp changed files to other directory/repository"},
{"login", "connect to git server"},
{"count", "count lines in project"},
{"tag", "show tags"},
{"tag add <tag>", "add tag"},
{"tag checkout <tag>", "checkout tag"},
{"tag delete <tag>", "delete tag"},
{"alias [name [cmd]]", "list, show or define an alias ($1..$N, $* args)"},
{"unalias <name>", "remove an alias"},
{"rescan", "refresh cached server repository list"},
{"!<command>", "run <command> 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()
}
+166
View File
@@ -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
}
+177
View File
@@ -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)
}
+72
View File
@@ -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)
}
+7
View File
@@ -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
+8
View File
@@ -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=
+46
View File
@@ -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()
}
+209
View File
@@ -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 <command> [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")
}
}
}
+423
View File
@@ -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)
}
}
}
+34
View File
@@ -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 = <personal-access-token>
# 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'
+108
View File
@@ -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: "<behind>\t<ahead>"
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
}
+65
View File
@@ -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()
}
+305
View File
@@ -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 = <api-token> 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 <remote> <args...>` 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
}
+94
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
4.0.11