Fix review findings, add per-project config and multi-target pushremote

Correctness and security fixes found by a review of the initial commit:

- init decided whether a server repository existed from the *local*
  remote.origin.url, so an unlinked project directory skipped the
  confirmation and rm -rf'd the remote history. It now asks the server,
  and aborts when the server cannot be reached.
- Project names and paths were interpolated unquoted into the remote
  shell command strings: a space split one path into two arguments and a
  backtick executed on the git server. Everything now goes through shq(),
  and chained remote commands use && so a failed cd cannot let the next
  command run in the login directory.
- Bare `cd` panicked with an index-out-of-range and took down the shell;
  it now deselects the project.
- Command-line mode set PRJ to the whole path below BASE, so `mgsh push`
  from a subdirectory staged only that subtree and addressed a bogus
  server path. It now truncates at the first path element.
- `list` hardcoded owner and group "git git" in its regex and silently
  printed nothing on any server where the repositories are owned by
  someone else.
- The config parser kept inline "#" comments in values although the
  README and the example file document them, so `mirror = true # ...`
  silently disabled mirroring.
- ~/.mgshrc holds an API token but was created world-readable.
- The mirror token was passed on git's command line, visible in the
  process table; it now goes through GIT_CONFIG_*.
- tag, count and dist ran without a repository and operated on BASE.
- checkout dropped its git options, because the dispatcher strips -x
  flags from the word list.
- REPO was read with a plain `git config`, inheriting a foreign origin
  from an enclosing repository; it is now local-only and, being dead
  state otherwise, no longer recomputed on every prompt.
- getkey consumed a single byte, leaving the rest of a typed answer in
  the tty queue where readline ran it as a command.
- The REPL spun on any readline error that was neither EOF nor interrupt.
- Tab completion cached an empty repository list after one failed ssh.
- Startup did a blocking DNS lookup and three `git config --global`
  writes on every invocation.

New:

- A project may carry its own .mgshrc, overriding the global settings
  while it is active. Resolution order is ~/.mgshrc -> <project>/.mgshrc
  -> MGSH_*; base and the git identity keys stay global. It is read when
  the project changes, and `rescan` reloads it.
- pushremote mirrors to any number of servers, configured as
  remote.<name>.url/key/type/visibility blocks. `pushremote` pushes to
  all of them, `pushremote @name ...` to a selection, and `remotes = ...`
  restricts and orders the set. Each target owns a git remote of the same
  name; a failing target no longer stops the others.
- `config` shows the resolved configuration, its sources and the mirror
  targets with masked tokens; `config -k` lists the setting names.
- gitkey was parsed and documented but never used. It is now the ssh
  identity for the git server, for mgsh's own ssh calls and, via
  GIT_SSH_COMMAND, for the git commands mgsh runs.
- config, count, login and cloneall work from the command line too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 11:37:49 +02:00
co-authored by Claude Opus 5
parent 5562055695
commit ae8c7a3ec0
11 changed files with 1480 additions and 203 deletions
+182 -103
View File
@@ -12,20 +12,62 @@ import (
)
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+`)
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 PRJ == "" {
errorln("no project selected")
if !requireProject() {
return false
}
if !isDir(DIR + "/.git") {
@@ -93,11 +135,23 @@ func runCommandDepth(line string, depth int) bool {
case "unalias": // remove a command alias
handleUnalias(word(words, 1))
case "rescan": // refresh the cached server repository list
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("server repository list refreshed")
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
@@ -126,22 +180,22 @@ func runCommandDepth(line string, depth int) bool {
fmt.Printf("%d files copied to %s\n", n, ddir)
case "list": // list repositories on the git server
path := "."
path, suffix := ".", ".git"
if opt["a"] {
path = "./archive"
path, suffix = "./archive", ".git.tar.gz"
}
pat := word(words, 1)
lines, _ := sshOut("/bin/ls -ltr " + path)
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
}
re := lsRepoRe
if opt["a"] {
re = lsArchRe
}
if m := re.FindStringSubmatch(ln); m != nil {
fmt.Println(colorRepoLine(m[1]))
if name := lsEntry(ln, suffix); name != "" {
fmt.Println(colorRepoLine(name))
}
}
@@ -150,19 +204,22 @@ func runCommandDepth(line string, depth int) bool {
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 !validProject(prj) {
errorln("usage: show <repository>")
break
}
if found == 1 {
logLines, _ := sshOut("cd " + cfg.GitPath + "/" + prj + ".git && git log --reverse --format='%h %ct %s'")
repolog(logLines)
} else {
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() {
@@ -203,8 +260,7 @@ func runCommandDepth(line string, depth int) bool {
gitOK(DIR, "fetch")
case "push": // commit everything and push to the server
if PRJ == "" {
errorln("no project selected")
if !requireProject() {
break
}
if fileExists(DIR + "/push.pl") {
@@ -221,9 +277,9 @@ func runCommandDepth(line string, depth int) bool {
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
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
@@ -245,8 +301,7 @@ func runCommandDepth(line string, depth int) bool {
}
case "archive": // snapshot the server-side repository into ./archive
if PRJ == "" {
errorln("no project selected")
if !requireProject() {
break
}
comment := sanitizeComment(strings.Join(fields[1:], " "))
@@ -255,89 +310,104 @@ func runCommandDepth(line string, depth int) bool {
if comment != "" {
name = PRJ + "_" + z + "_" + comment
}
if !sshOK("cp -r " + PRJ + ".git archive/" + name + ".git") {
if !sshOK("cp -r " + shq(PRJ+".git") + " " + shq("archive/"+name+".git")) {
break
}
if !sshOK("cd archive;tar cvzf " + name + ".git.tar.gz " + name + ".git") {
if !sshOK("cd archive && tar cvzf " + shq(name+".git.tar.gz") + " " + shq(name+".git")) {
break
}
sshOK("rm -rf archive/" + name + ".git")
sshOK("rm -rf " + shq("archive/"+name+".git"))
case "init": // create a new repository from the current directory
if PRJ == "" {
errorln("no project selected")
if !requireProject() {
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")
// 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", "-p", cfg.GitPort, cfg.GitUser+"@"+cfg.GitHost)
runInDir("", "ssh", sshArgs(cfg.GitUser+"@"+cfg.GitHost)...)
case "cd": // change the current project
if strings.HasPrefix(word(words, 1), ".") {
arg := word(words, 1)
if strings.HasPrefix(arg, ".") {
break // ignore filesystem-relative navigation
}
PRJ = ""
if isDir(BASE + "/" + word(words, 1)) {
PRJ = words[1]
PRJ = "" // bare `cd` deselects the project and returns to BASE
if validProject(arg) && isDir(BASE+"/"+arg) {
PRJ = arg
}
case "checkout":
if !requireRepo() {
break
}
gitOK(DIR, "checkout", word(words, 1))
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
}
path := "."
if !validProject(prj) {
errorln("invalid repository name: " + prj)
break
}
path, entry := ".", prj+".git"
if opt["a"] {
path = "./archive"
path, entry = "./archive", prj+".git.tar.gz"
}
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++
}
exists, err := serverEntryExists(path, entry)
if err != nil {
errorln("could not reach the git server: " + err.Error())
break
}
if found != 1 {
if !exists {
errorln("repository not found, try 'list [-a]'")
break
}
@@ -353,13 +423,13 @@ func runCommandDepth(line string, depth int) bool {
break
}
} else {
if !sshOK("cd archive;tar xvzf " + prj + ".git.tar.gz") {
if !sshOK("cd archive && tar xvzf " + shq(prj+".git.tar.gz")) {
break
}
if !gitOK(BASE, "clone", URL+"/archive/"+prj+".git") {
break
}
sshOK("rm -rf archive/" + prj + ".git")
sshOK("rm -rf " + shq("archive/"+prj+".git"))
}
if isDir(BASE + "/" + prj) {
PRJ = prj
@@ -370,7 +440,11 @@ func runCommandDepth(line string, depth int) bool {
if opt["a"] {
path = "./archive"
}
lines, _ := sshOut("/bin/ls " + path)
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 {
@@ -385,7 +459,7 @@ func runCommandDepth(line string, depth int) bool {
prj = w
}
d := BASE + "/" + prj
if !isDir(d) {
if !validProject(prj) || !isDir(d) {
errorln("not found")
break
}
@@ -400,16 +474,14 @@ func runCommandDepth(line string, depth int) bool {
}
}
}
if xws != "" && xprj != "" {
xprj = "" // prefer the workspace
}
if xws != "" && isDir(d+"/"+xws) {
// 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)
}
if xprj != "" && isDir(d+"/"+xprj) {
case xprj != "" && isDir(d+"/"+xprj):
runInDir(d, "open", xprj)
}
if xws == "" && xprj == "" {
default:
editor := cfg.Editor
if editor == "" {
editor = "coda"
@@ -421,9 +493,15 @@ func runCommandDepth(line string, depth int) bool {
}
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) != "":
@@ -603,11 +681,11 @@ const gitignore = `.DS_Store
`
var helpItems = []struct{ cmd, desc string }{
{"cd [project]", "change project"},
{"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 [desc]", "mirror repo to a public server (gitea/github/gitlab) via API"},
{"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)"},
@@ -630,7 +708,8 @@ var helpItems = []struct{ cmd, desc string }{
{"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"},
{"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"},
}
@@ -640,7 +719,7 @@ func help() {
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.Printf(" %s%s\n", col(cGreen, padRight(it.cmd, 27)), it.desc)
}
fmt.Println()
}