package main import ( "bytes" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "time" ) var ( optRe = regexp.MustCompile(`^-(\w)$`) numRe = regexp.MustCompile(`^\d+$`) // a `ls -ltr` long-listing line: mode, link count, owner, group, size, then // the date columns and the name. Owner and group are matched as opaque // fields — the bare repositories need not belong to a user or group // literally named "git". lsEntryRe = regexp.MustCompile(`^\S+\s+\d+\s+\S+\s+\S+\s+\d+\s+(.*)$`) gitDirRe = regexp.MustCompile(`^(.*)\.git$`) sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`) wsRe = regexp.MustCompile(`\s+`) ) // lsEntry extracts the " " tail of a `ls -ltr` line whose // entry name ends in suffix, with the suffix removed. It returns "" for any // other line (the leading "total" line, entries of a different kind). func lsEntry(line, suffix string) string { m := lsEntryRe.FindStringSubmatch(strings.TrimSpace(line)) if m == nil { return "" } name := m[1] if i := strings.Index(name, " -> "); i >= 0 { name = name[:i] // a symlinked bare repo lists as "link.git -> target.git" } if !strings.HasSuffix(name, suffix) { return "" } return strings.TrimSuffix(name, suffix) } // validProject reports whether name is usable as a project name: a single path // element directly under BASE. Names containing a separator would escape BASE // and turn the remote ".git" paths into something else entirely. func validProject(name string) bool { return name != "" && !strings.HasPrefix(name, ".") && !strings.ContainsAny(name, "/\\") } // requireProject checks that a usable project is active, printing an error // otherwise. Returns true when it is safe to proceed. func requireProject() bool { if PRJ == "" { errorln("no project selected") return false } if !validProject(PRJ) { errorln("invalid project name: " + PRJ) return false } return true } // 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 !requireProject() { 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 "config": // show the effective configuration and where it came from if opt["k"] { fmt.Println(strings.Join(configKeys(), "\n")) fmt.Println("remote..url|key|type|visibility") break } showConfig() case "rescan": // reload the configuration and the cached server repo list reloadConfig() rescanServer() fmt.Println("configuration and server repository list refreshed") case "dist": // cp changed files to another directory/repository if !requireRepo() { // `git ls-files` below would otherwise walk BASE break } 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, suffix := ".", ".git" if opt["a"] { path, suffix = "./archive", ".git.tar.gz" } pat := word(words, 1) lines, err := sshOut("/bin/ls -ltr " + shq(path)) if err != nil { errorln("could not list repositories on the git server") break } for _, ln := range lines { if pat != "" && !strings.Contains(strings.ToLower(ln), strings.ToLower(pat)) { continue } if name := lsEntry(ln, suffix); name != "" { fmt.Println(colorRepoLine(name)) } } case "show": // show a repository's log directly on the server prj := PRJ if w := word(words, 1); w != "" { prj = w } if !validProject(prj) { errorln("usage: show ") break } exists, err := serverEntryExists(".", prj+".git") if err != nil { errorln("could not reach the git server") break } if !exists { errorln("repository not found") break } logLines, _ := sshOut("cd " + shq(cfg.GitPath+"/"+prj+".git") + " && git log --reverse --format='%h %ct %s'") repolog(logLines) 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 !requireProject() { 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", ".") // last look before anything is committed: `add --all` sweeps up whatever // is lying around, and with mirroring on it goes straight to a public // server. Nothing has been committed yet, so declining costs nothing. if !secretsApproved(DIR) { errorln("push cancelled — your changes are staged but not committed") break } 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 " + shq(cfg.GitPath+"/"+PRJ+".git")) if targets, _ := cfg.mirrorTargets(); truthy(cfg.Mirror) && len(targets) > 0 { handlePushRemote("") // auto-mirror to every configured server } case "pushremote": // mirror the repo to a public git server via its API handlePushRemote(strings.Join(fields[1:], " ")) case "release": // tag a commit and publish it as a release on the mirrors handleRelease(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 !requireProject() { break } // like `init`/`clone`/`show`, ask the server before acting on it — // otherwise a missing repository surfaces as a raw `cp -r` failure exists, err := serverEntryExists(".", PRJ+".git") if err != nil { errorln("could not reach the git server: " + err.Error()) break } if !exists { errorln("no repository " + PRJ + " on the git server — run 'init' first") break } comment := sanitizeComment(strings.Join(fields[1:], " ")) z := archiveStamp() name := PRJ + "_" + z if comment != "" { name = PRJ + "_" + z + "_" + comment } if !sshOK("cp -r " + shq(PRJ+".git") + " " + shq("archive/"+name+".git")) { break } if !sshOK("cd archive && tar cvzf " + shq(name+".git.tar.gz") + " " + shq(name+".git")) { break } sshOK("rm -rf " + shq("archive/"+name+".git")) case "init": // create a new repository from the current directory if !requireProject() { break } if fileExists(DIR + "/push.pl") { runInDir(DIR, "perl", DIR+"/push.pl") } // Whether a repository would be destroyed is a property of the *server*, // not of this checkout's remote.origin.url — an unlinked project // directory says nothing about what is on the other end. exists, err := serverEntryExists(".", PRJ+".git") if err != nil { errorln("could not reach the git server: " + err.Error()) break } if exists && !yesno("overwrite existing repository "+PRJ+" on the server?", false) { break } remote := shq(cfg.GitPath + "/" + PRJ + ".git") if !sshOK("rm -rf " + remote) { break } if !sshOK("mkdir " + remote + " && cd " + remote + " && git --bare init") { break } gi := DIR + "/.gitignore" if !fileExists(gi) || yesno("overwrite existing .gitignore?", false) { if err := os.WriteFile(gi, []byte(gitignore), 0644); err != nil { errorln("could not write " + gi + ": " + err.Error()) } } 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]", resolveIP(), USER)) { break } gitOK(DIR, "push", "-u", "origin", "master") case "login": // open an interactive ssh session to the server runInDir("", "ssh", sshArgs(cfg.GitUser+"@"+cfg.GitHost)...) case "cd": // change the current project arg := word(words, 1) if strings.HasPrefix(arg, ".") { break // ignore filesystem-relative navigation } PRJ = "" // bare `cd` deselects the project and returns to BASE if validProject(arg) && isDir(BASE+"/"+arg) { PRJ = arg } case "checkout": if !requireRepo() { break } if len(fields) < 2 { errorln("usage: checkout ") break } // forward the raw fields, not the option-stripped words: `checkout -b // topic` has to reach git with its flag intact. gitOK(DIR, append([]string{"checkout"}, fields[1:]...)...) case "clone": // clone a repository (or archive with -a) from the server prj := PRJ if w := word(words, 1); w != "" { prj = w } if !validProject(prj) { errorln("invalid repository name: " + prj) break } path, entry := ".", prj+".git" if opt["a"] { path, entry = "./archive", prj+".git.tar.gz" } exists, err := serverEntryExists(path, entry) if err != nil { errorln("could not reach the git server: " + err.Error()) break } if !exists { 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 " + shq(prj+".git.tar.gz")) { break } if !gitOK(BASE, "clone", URL+"/archive/"+prj+".git") { break } sshOK("rm -rf " + shq("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, err := sshOut("/bin/ls " + shq(path)) if err != nil { errorln("could not list repositories on the git server") break } 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 !validProject(prj) || !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() } } } // prefer the workspace over the project; fall back to the editor unless // one of them is really openable (a name match on a plain file is not). switch { case xws != "" && isDir(d+"/"+xws): runInDir(d, "open", xws) case xprj != "" && isDir(d+"/"+xprj): runInDir(d, "open", xprj) default: 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 if !requireProject() { break } countLines(DIR) case "tag": // manage tags if !requireRepo() { break } sub := word(words, 1) switch { case sub == "add" && word(words, 2) != "": // `tag add v1.0 why this exists` annotates with that text; without // one the tag name is the message, as before. The annotation is // what `release` falls back to for its notes. msg := words[2] if len(fields) > 3 { msg = strings.Join(fields[3:], " ") } if gitOK(DIR, "tag", "-a", words[2], "-m", msg) { 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 (no argument: back to the base)"}, {"open [project]", "open project"}, {"init", "make new repository from current directory"}, {"push [comment]", "push changes to git server"}, {"pushremote [@name] [desc]", "mirror repo to the public server(s) (gitea/github/gitlab)"}, {"release [notes]", "tag and publish a release on the public server(s)"}, {"pull", "pull changes from git server"}, {"fetch", "fetch changes from git server"}, {"status [-a]", "short git status (-a: overview of all projects)"}, {"overview", "inventory of all projects, local and on the server"}, {"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 [msg]", "add tag (msg becomes the annotation)"}, {"tag checkout ", "checkout tag"}, {"tag delete ", "delete tag"}, {"alias [name [cmd]]", "list, show or define an alias ($1..$N, $* args)"}, {"unalias ", "remove an alias"}, {"config [-k]", "show effective configuration (-k: list all setting names)"}, {"rescan", "reload config and 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, 27)), it.desc) } fmt.Println() }