Files
mgsh/commands.go
T
mikeandClaude Opus 5 689a3fe594 Make the ssh layer injectable and cover the destructive server commands
The remote commands are the only code in mgsh that can destroy data, and
they were the least verifiable: each one is a single string handed to a
login shell, so a missing quote silently changes which paths it touches.
Every one of them now goes through the sshExec variable, and yesno is a
variable too, so a test can record what would have been sent and answer
the confirmations without a terminal.

The tests pin down what the previous commit fixed by reasoning alone:
that a declined or unreachable `init` sends no rm -rf, that a project
named "my 'weird' project" reaches the server fully quoted, and that
archive's cp/tar/rm sequence is named, ordered and quoted correctly.
push is driven end to end against a real local bare repository.

archive also gained the server-side existence check that init, clone and
show already had, so a project that was never pushed reports that instead
of failing inside cp -r.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:38:22 +02:00

737 lines
20 KiB
Go

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 "<date columns> <name>" 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 "<name>.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.<name>.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 <repository>")
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", ".")
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 "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 <branch|tag|git options>")
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) != "":
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 (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)"},
{"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"},
{"config [-k]", "show effective configuration (-k: list all setting names)"},
{"rescan", "reload config and 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, 27)), it.desc)
}
fmt.Println()
}