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