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:
+182
-103
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user