initial commit [141.14.129.234,mike]

This commit is contained in:
2026-07-26 06:38:01 +02:00
commit 5562055695
20 changed files with 2898 additions and 0 deletions
+646
View File
@@ -0,0 +1,646 @@
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
var (
optRe = regexp.MustCompile(`^-(\w)$`)
numRe = regexp.MustCompile(`^\d+$`)
lsRepoRe = regexp.MustCompile(`git\s+git\s+\d+\s+(.*)\.git$`)
lsArchRe = regexp.MustCompile(`git\s+git\s+\d+\s+(.*)\.git\.tar\.gz$`)
gitDirRe = regexp.MustCompile(`^(.*)\.git$`)
sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`)
wsRe = regexp.MustCompile(`\s+`)
)
// requireRepo checks that a project with a .git is active, printing an error
// otherwise. Returns true when it is safe to proceed.
func requireRepo() bool {
if PRJ == "" {
errorln("no project selected")
return false
}
if !isDir(DIR + "/.git") {
errorln("error, no repository (.git) found")
return false
}
return true
}
// runCommand parses one command line and dispatches it. It returns false when
// the shell should terminate (quit/exit).
func runCommand(line string) bool {
return runCommandDepth(line, 0)
}
// runCommandDepth is runCommand with an alias-expansion recursion counter.
func runCommandDepth(line string, depth int) bool {
line = strings.TrimSpace(line)
// A leading '!' runs the rest of the line as a shell command. Nothing else
// is forwarded to the shell any more.
if strings.HasPrefix(line, "!") {
if sh := strings.TrimSpace(line[1:]); sh != "" {
forwardShell(sh)
}
return true
}
// Expand a user-defined alias in the first word, then re-dispatch. The
// depth guard prevents runaway/cyclic expansion.
if depth < maxAliasDepth {
if f := strings.Fields(line); len(f) > 0 {
if body, ok := aliases[f[0]]; ok && !isBuiltin(f[0]) {
return runCommandDepth(expandAlias(body, f[1:]), depth+1)
}
}
}
fields := strings.Fields(line)
// separate `-x` option flags from positional words.
var words []string
opt := map[string]bool{}
for _, f := range fields {
if m := optRe.FindStringSubmatch(f); m != nil {
opt[m[1]] = true
} else {
words = append(words, f)
}
}
switch word(words, 0) {
case "": // empty line or options only — nothing to do
case "quit", "exit":
return false
case "help":
help()
case "alias": // list, show or define a command alias (persisted)
handleAlias(line)
case "unalias": // remove a command alias
handleUnalias(word(words, 1))
case "rescan": // refresh the cached server repository list
rescanServer()
fmt.Println("server repository list refreshed")
case "dist": // cp changed files to another directory/repository
ddir := BASE + "/dist/" + PRJ
if w := word(words, 1); w != "" {
ddir = w
}
if !isDir(ddir) {
errorln(fmt.Sprintf("dist path not found (%s)", ddir))
break
}
n := 0
out, _ := gitCapture(DIR, "ls-files")
for _, f := range splitLines(out) {
if f == "" || strings.HasPrefix(f, "private") {
continue
}
src := filepath.Join(DIR, f)
dst := filepath.Join(ddir, f)
if !filesEqual(src, dst) {
fmt.Println(f)
if err := copyFile(src, dst); err != nil {
fmt.Fprintln(os.Stderr, col(cRed, fmt.Sprintf("copy %s: %v", f, err)))
continue
}
n++
}
}
fmt.Printf("%d files copied to %s\n", n, ddir)
case "list": // list repositories on the git server
path := "."
if opt["a"] {
path = "./archive"
}
pat := word(words, 1)
lines, _ := sshOut("/bin/ls -ltr " + path)
for _, ln := range lines {
if pat != "" && !strings.Contains(strings.ToLower(ln), strings.ToLower(pat)) {
continue
}
re := lsRepoRe
if opt["a"] {
re = lsArchRe
}
if m := re.FindStringSubmatch(ln); m != nil {
fmt.Println(colorRepoLine(m[1]))
}
}
case "show": // show a repository's log directly on the server
prj := PRJ
if w := word(words, 1); w != "" {
prj = w
}
found := 0
lines, _ := sshOut("/bin/ls .")
for _, ln := range lines {
if strings.TrimSpace(ln) == prj+".git" {
found++
}
}
if found == 1 {
logLines, _ := sshOut("cd " + cfg.GitPath + "/" + prj + ".git && git log --reverse --format='%h %ct %s'")
repolog(logLines)
} else {
errorln("repository not found")
}
case "log":
if !requireRepo() {
break
}
out, _ := gitCapture(DIR, "log", "--reverse", "--format=%h %ct %s")
repolog(splitLines(out))
case "status": // short git status of the active project (-a: all projects)
if opt["a"] {
overviewAll()
break
}
if !requireRepo() {
break
}
git(DIR, "status", "-sb")
case "overview": // status summary of all projects under BASE
overviewAll()
case "diff": // git diff of the active project (with optional args)
if !requireRepo() {
break
}
git(DIR, append([]string{"diff"}, fields[1:]...)...)
case "pull":
if !requireRepo() {
break
}
gitOK(DIR, "pull")
case "fetch":
if !requireRepo() {
break
}
gitOK(DIR, "fetch")
case "push": // commit everything and push to the server
if PRJ == "" {
errorln("no project selected")
break
}
if fileExists(DIR + "/push.pl") {
runInDir(DIR, "perl", DIR+"/push.pl")
}
if !isDir(DIR + "/.git") {
errorln("error, no repository (.git) found")
break
}
comment := strings.Join(fields[1:], " ")
git(DIR, "add", "--all", ".")
msg := strings.TrimSpace(fmt.Sprintf("[%s@%s] %s", USER, HOST, comment))
git(DIR, "commit", "-m", msg) // may be "nothing to commit"; continue anyway
if !gitOK(DIR, "push") {
break
}
sshOK("touch " + cfg.GitPath + "/" + PRJ + ".git")
if truthy(cfg.Mirror) && cfg.RemoteURL != "" && cfg.RemoteKey != "" {
handlePushRemote("") // auto-mirror to the public server
}
case "pushremote": // mirror the repo to a public git server via its API
handlePushRemote(strings.Join(fields[1:], " "))
case "edit": // interactively edit the last N commits
if !requireRepo() {
break
}
num := "10"
if w := word(words, 1); numRe.MatchString(w) {
num = w
}
if !gitOK(DIR, "rebase", "-i", "HEAD~"+num) {
break
}
if yesno("force-push rewritten history?", false) {
gitOK(DIR, "push", "--force")
}
case "archive": // snapshot the server-side repository into ./archive
if PRJ == "" {
errorln("no project selected")
break
}
comment := sanitizeComment(strings.Join(fields[1:], " "))
z := archiveStamp()
name := PRJ + "_" + z
if comment != "" {
name = PRJ + "_" + z + "_" + comment
}
if !sshOK("cp -r " + PRJ + ".git archive/" + name + ".git") {
break
}
if !sshOK("cd archive;tar cvzf " + name + ".git.tar.gz " + name + ".git") {
break
}
sshOK("rm -rf archive/" + name + ".git")
case "init": // create a new repository from the current directory
if PRJ == "" {
errorln("no project selected")
break
}
if fileExists(DIR + "/push.pl") {
runInDir(DIR, "perl", DIR+"/push.pl")
}
if REPO == "" || yesno("overwrite existing repository?", false) {
if !sshOK("rm -rf " + cfg.GitPath + "/" + PRJ + ".git") {
break
}
if !sshOK("mkdir " + cfg.GitPath + "/" + PRJ + ".git;cd " + cfg.GitPath + "/" + PRJ + ".git;git --bare init") {
break
}
gi := DIR + "/.gitignore"
if !fileExists(gi) || yesno("overwrite existing .gitignore?", false) {
os.WriteFile(gi, []byte(gitignore), 0644)
}
os.RemoveAll(DIR + "/.git")
if !gitOK(DIR, "init") {
break
}
if !gitOK(DIR, "remote", "add", "origin", URL+"/"+PRJ+".git/") {
break
}
if !gitOK(DIR, "add", ".") {
break
}
if !gitOK(DIR, "commit", "-m", fmt.Sprintf("initial commit [%s,%s]", IP, USER)) {
break
}
gitOK(DIR, "push", "-u", "origin", "master")
}
case "login": // open an interactive ssh session to the server
runInDir("", "ssh", "-p", cfg.GitPort, cfg.GitUser+"@"+cfg.GitHost)
case "cd": // change the current project
if strings.HasPrefix(word(words, 1), ".") {
break // ignore filesystem-relative navigation
}
PRJ = ""
if isDir(BASE + "/" + word(words, 1)) {
PRJ = words[1]
}
case "checkout":
if !requireRepo() {
break
}
gitOK(DIR, "checkout", word(words, 1))
case "clone": // clone a repository (or archive with -a) from the server
prj := PRJ
if w := word(words, 1); w != "" {
prj = w
}
path := "."
if opt["a"] {
path = "./archive"
}
found := 0
lines, _ := sshOut("/bin/ls " + path)
for _, ln := range lines {
t := strings.TrimSpace(ln)
if opt["a"] {
if t == prj+".git.tar.gz" {
found++
}
} else if t == prj+".git" {
found++
}
}
if found != 1 {
errorln("repository not found, try 'list [-a]'")
break
}
if isDir(BASE + "/" + prj) {
if yesno("overwrite existing directory?", false) {
os.RemoveAll(BASE + "/" + prj)
} else {
break
}
}
if !opt["a"] {
if !gitOK(BASE, "clone", URL+"/"+prj+".git") {
break
}
} else {
if !sshOK("cd archive;tar xvzf " + prj + ".git.tar.gz") {
break
}
if !gitOK(BASE, "clone", URL+"/archive/"+prj+".git") {
break
}
sshOK("rm -rf archive/" + prj + ".git")
}
if isDir(BASE + "/" + prj) {
PRJ = prj
}
case "cloneall": // clone every repository found on the server
path := "."
if opt["a"] {
path = "./archive"
}
lines, _ := sshOut("/bin/ls " + path)
for _, ln := range lines {
t := strings.TrimSpace(ln)
if m := gitDirRe.FindStringSubmatch(t); m != nil {
fmt.Printf("-%s-\n", m[1])
gitOK(BASE, "clone", URL+"/"+m[1]+".git")
}
}
case "open", "view": // open project in Xcode / editor
prj := PRJ
if w := word(words, 1); w != "" {
prj = w
}
d := BASE + "/" + prj
if !isDir(d) {
errorln("not found")
break
}
xws, xprj := "", ""
if entries, err := os.ReadDir(d); err == nil {
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".xcworkspace") {
xws = e.Name()
}
if strings.HasSuffix(e.Name(), ".xcodeproj") {
xprj = e.Name()
}
}
}
if xws != "" && xprj != "" {
xprj = "" // prefer the workspace
}
if xws != "" && isDir(d+"/"+xws) {
runInDir(d, "open", xws)
}
if xprj != "" && isDir(d+"/"+xprj) {
runInDir(d, "open", xprj)
}
if xws == "" && xprj == "" {
editor := cfg.Editor
if editor == "" {
editor = "coda"
}
runInDir(d, editor, d)
}
if words[0] == "open" {
PRJ = prj
}
case "count": // count source lines in the project
countLines(DIR)
case "tag": // manage tags
sub := word(words, 1)
switch {
case sub == "add" && word(words, 2) != "":
if gitOK(DIR, "tag", "-a", words[2], "-m", words[2]) {
gitOK(DIR, "push", "origin", words[2])
}
case sub == "checkout" && word(words, 2) != "":
gitOK(DIR, "checkout", "tags/"+words[2])
case sub == "delete" && word(words, 2) != "":
if gitOK(DIR, "tag", "-d", words[2]) {
gitOK(DIR, "push", "origin", ":refs/tags/"+words[2])
}
case sub != "":
errorln("unkown tag subcommand")
default:
git(DIR, "tag", "-l", "--format=%(taggerdate:short): %(refname:short)")
}
default: // unknown command — no longer forwarded to the shell
fmt.Println(col(cRed, "unknown command: "+words[0]) +
col(cGray, " (prefix with '!' to run a shell command)"))
}
return true
}
// ---------------------------------------------------------------------------
// repository log formatting
// ---------------------------------------------------------------------------
// repolog prints the formatted log for "hash<2sp>epoch<2sp>subject" lines.
func repolog(lines []string) {
fmt.Print(formatLog(lines, time.Now()))
}
// formatLog renders the log, picking the most compact date column that still
// disambiguates every entry. Returned as a string for testability.
func formatLog(lines []string, now time.Time) string {
days := [...]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
months := [...]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
var full, short, tiny strings.Builder
n, ns := 0, 0
for _, ln := range lines {
if strings.TrimSpace(ln) == "" {
continue
}
parts := strings.SplitN(ln, " ", 3)
if len(parts) < 3 {
continue
}
hash := parts[0]
epoch, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
if err != nil {
continue
}
subj := parts[2]
t := time.Unix(epoch, 0)
age := now.Sub(t)
wd := int(t.Weekday())
var z, zs, zss string
switch {
case age < 24*time.Hour:
z = fmt.Sprintf(" %02d:%02d", t.Hour(), t.Minute())
zs = fmt.Sprintf(" %02d:%02d", t.Hour(), t.Minute())
zss = fmt.Sprintf("%02d:%02d", t.Hour(), t.Minute())
case age < 7*24*time.Hour:
z = fmt.Sprintf(" %s, %02d:%02d", days[wd], t.Hour(), t.Minute())
zs = fmt.Sprintf("%s, %02d:%02d", days[wd], t.Hour(), t.Minute())
zss = zs
ns++
default:
z = fmt.Sprintf("%s, %02d %s %4d, %02d:%02d",
days[wd], t.Day(), months[t.Month()-1], t.Year(), t.Hour(), t.Minute())
zs, zss = z, z
n++
}
fmt.Fprintf(&full, "%s %s %s\n", col(cPurple, hash), col(cYellow, z), subj)
fmt.Fprintf(&short, "%s %s %s\n", col(cPurple, hash), col(cYellow, zs), subj)
fmt.Fprintf(&tiny, "%s %s %s\n", col(cPurple, hash), col(cYellow, zss), subj)
}
switch {
case n == 0 && ns == 0:
return tiny.String()
case n == 0 && ns > 0:
return short.String()
default:
return full.String()
}
}
// ---------------------------------------------------------------------------
// line counting
// ---------------------------------------------------------------------------
// countLines prints the line total over all source files in the project.
func countLines(dir string) {
total, files := countSourceLines(dir)
fmt.Printf("%d lines total in %d files in %s\n", total, files, PRJ)
}
// countSourceLines walks dir and sums the lines of every source file, returning
// the line and file totals. A "source file" is any text (non-binary) file;
// hidden directories (.git, …) and hidden files are skipped.
func countSourceLines(dir string) (lines, files int) {
filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error {
if err != nil {
return nil
}
if fi.IsDir() {
if p != dir && strings.HasPrefix(fi.Name(), ".") {
return filepath.SkipDir
}
return nil
}
if strings.HasPrefix(fi.Name(), ".") || !isTextFile(p) {
return nil
}
lines += countFileLines(p)
files++
return nil
})
return lines, files
}
func countFileLines(path string) int {
data, err := os.ReadFile(path)
if err != nil {
return 0
}
return bytes.Count(data, []byte{'\n'})
}
func isTextFile(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
buf := make([]byte, 512)
n, _ := f.Read(buf)
return !bytes.Contains(buf[:n], []byte{0})
}
// ---------------------------------------------------------------------------
// misc command helpers
// ---------------------------------------------------------------------------
func sanitizeComment(s string) string {
s = sanRe.ReplaceAllString(s, " ")
s = strings.TrimSpace(s)
s = wsRe.ReplaceAllString(s, "_")
return s
}
// archiveStamp builds the DDMMYY.HHMM timestamp used for archive names.
func archiveStamp() string {
t := time.Now()
return fmt.Sprintf("%02d%02d%02d.%02d%02d",
t.Day(), int(t.Month()), t.Year()-2000, t.Hour(), t.Minute())
}
const gitignore = `.DS_Store
.AppleDouble
.LSOverride
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
`
var helpItems = []struct{ cmd, desc string }{
{"cd [project]", "change project"},
{"open [project]", "open project"},
{"init", "make new repository from current directory"},
{"push [comment]", "push changes to git server"},
{"pushremote [desc]", "mirror repo to a public server (gitea/github/gitlab) via API"},
{"pull", "pull changes from git server"},
{"fetch", "fetch changes from git server"},
{"status [-a]", "short git status (-a: overview of all projects)"},
{"overview", "status of all projects (dirty, ahead/behind)"},
{"diff [args]", "show git diff"},
{"edit [number]", "edit last [number] commits (default is 10)"},
{"clone [-a] <repository>", "clone repository from git server (-a for archive)"},
{"cloneall", "clone all repository from git server"},
{"checkout [git options]", "forward checkout to git"},
{"log", "show log"},
{"archive [comment]", "archive current repository"},
{"list [-a] [pattern]", "list repositories on git server (-a for archive)"},
{"show <repository>", "show repository log on git server"},
{"dist <path>", "cp changed files to other directory/repository"},
{"login", "connect to git server"},
{"count", "count lines in project"},
{"tag", "show tags"},
{"tag add <tag>", "add tag"},
{"tag checkout <tag>", "checkout tag"},
{"tag delete <tag>", "delete tag"},
{"alias [name [cmd]]", "list, show or define an alias ($1..$N, $* args)"},
{"unalias <name>", "remove an alias"},
{"rescan", "refresh cached server repository list"},
{"!<command>", "run <command> in the shell"},
{"quit", "exit mgsh"},
}
func help() {
fmt.Println()
fmt.Printf("%s v%s %s, builtin commands:\n\n",
col(cBold+cWhite, "mgsh (git shell)"), col(cYellow, VERSION), INFO)
for _, it := range helpItems {
fmt.Printf(" %s%s\n", col(cGreen, padRight(it.cmd, 25)), it.desc)
}
fmt.Println()
}