initial commit [141.14.129.234,mike]
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
mgsh
|
||||
@@ -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.
|
||||
@@ -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() }
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
4.0.11
|
||||
Reference in New Issue
Block a user