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
+1
View File
@@ -8,3 +8,4 @@
.TemporaryItems .TemporaryItems
.Trashes .Trashes
mgsh mgsh
.mgshrc
+4 -2
View File
@@ -47,7 +47,7 @@ var builtinCmds = map[string]bool{
"pushremote": true, "overview": true, "archive": true, "init": true, "pushremote": true, "overview": true, "archive": true, "init": true,
"login": true, "cd": true, "checkout": true, "clone": true, "cloneall": true, "login": true, "cd": true, "checkout": true, "clone": true, "cloneall": true,
"open": true, "view": true, "count": true, "tag": true, "alias": true, "open": true, "view": true, "count": true, "tag": true, "alias": true,
"unalias": true, "unalias": true, "config": true,
} }
func isBuiltin(name string) bool { return builtinCmds[name] } func isBuiltin(name string) bool { return builtinCmds[name] }
@@ -169,7 +169,9 @@ func saveAliases() {
if blk := aliasBlock(aliases); blk != "" { if blk := aliasBlock(aliases); blk != "" {
b.WriteString("\n" + blk) b.WriteString("\n" + blk)
} }
if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil { // os.WriteFile only applies the mode when creating the file, so an existing
// ~/.mgshrc keeps whatever the user set; a fresh one is created private.
if err := os.WriteFile(path, []byte(b.String()), configMode); err != nil {
errorln("could not save aliases: " + err.Error()) errorln("could not save aliases: " + err.Error())
} }
} }
+182 -103
View File
@@ -12,20 +12,62 @@ import (
) )
var ( var (
optRe = regexp.MustCompile(`^-(\w)$`) optRe = regexp.MustCompile(`^-(\w)$`)
numRe = regexp.MustCompile(`^\d+$`) numRe = regexp.MustCompile(`^\d+$`)
lsRepoRe = regexp.MustCompile(`git\s+git\s+\d+\s+(.*)\.git$`) // a `ls -ltr` long-listing line: mode, link count, owner, group, size, then
lsArchRe = regexp.MustCompile(`git\s+git\s+\d+\s+(.*)\.git\.tar\.gz$`) // the date columns and the name. Owner and group are matched as opaque
gitDirRe = regexp.MustCompile(`^(.*)\.git$`) // fields — the bare repositories need not belong to a user or group
sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`) // literally named "git".
wsRe = regexp.MustCompile(`\s+`) 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 // requireRepo checks that a project with a .git is active, printing an error
// otherwise. Returns true when it is safe to proceed. // otherwise. Returns true when it is safe to proceed.
func requireRepo() bool { func requireRepo() bool {
if PRJ == "" { if !requireProject() {
errorln("no project selected")
return false return false
} }
if !isDir(DIR + "/.git") { if !isDir(DIR + "/.git") {
@@ -93,11 +135,23 @@ func runCommandDepth(line string, depth int) bool {
case "unalias": // remove a command alias case "unalias": // remove a command alias
handleUnalias(word(words, 1)) 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() rescanServer()
fmt.Println("server repository list refreshed") fmt.Println("configuration and server repository list refreshed")
case "dist": // cp changed files to another directory/repository case "dist": // cp changed files to another directory/repository
if !requireRepo() { // `git ls-files` below would otherwise walk BASE
break
}
ddir := BASE + "/dist/" + PRJ ddir := BASE + "/dist/" + PRJ
if w := word(words, 1); w != "" { if w := word(words, 1); w != "" {
ddir = w ddir = w
@@ -126,22 +180,22 @@ func runCommandDepth(line string, depth int) bool {
fmt.Printf("%d files copied to %s\n", n, ddir) fmt.Printf("%d files copied to %s\n", n, ddir)
case "list": // list repositories on the git server case "list": // list repositories on the git server
path := "." path, suffix := ".", ".git"
if opt["a"] { if opt["a"] {
path = "./archive" path, suffix = "./archive", ".git.tar.gz"
} }
pat := word(words, 1) 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 { for _, ln := range lines {
if pat != "" && !strings.Contains(strings.ToLower(ln), strings.ToLower(pat)) { if pat != "" && !strings.Contains(strings.ToLower(ln), strings.ToLower(pat)) {
continue continue
} }
re := lsRepoRe if name := lsEntry(ln, suffix); name != "" {
if opt["a"] { fmt.Println(colorRepoLine(name))
re = lsArchRe
}
if m := re.FindStringSubmatch(ln); m != nil {
fmt.Println(colorRepoLine(m[1]))
} }
} }
@@ -150,19 +204,22 @@ func runCommandDepth(line string, depth int) bool {
if w := word(words, 1); w != "" { if w := word(words, 1); w != "" {
prj = w prj = w
} }
found := 0 if !validProject(prj) {
lines, _ := sshOut("/bin/ls .") errorln("usage: show <repository>")
for _, ln := range lines { break
if strings.TrimSpace(ln) == prj+".git" {
found++
}
} }
if found == 1 { exists, err := serverEntryExists(".", prj+".git")
logLines, _ := sshOut("cd " + cfg.GitPath + "/" + prj + ".git && git log --reverse --format='%h %ct %s'") if err != nil {
repolog(logLines) errorln("could not reach the git server")
} else { break
}
if !exists {
errorln("repository not found") errorln("repository not found")
break
} }
logLines, _ := sshOut("cd " + shq(cfg.GitPath+"/"+prj+".git") +
" && git log --reverse --format='%h %ct %s'")
repolog(logLines)
case "log": case "log":
if !requireRepo() { if !requireRepo() {
@@ -203,8 +260,7 @@ func runCommandDepth(line string, depth int) bool {
gitOK(DIR, "fetch") gitOK(DIR, "fetch")
case "push": // commit everything and push to the server case "push": // commit everything and push to the server
if PRJ == "" { if !requireProject() {
errorln("no project selected")
break break
} }
if fileExists(DIR + "/push.pl") { if fileExists(DIR + "/push.pl") {
@@ -221,9 +277,9 @@ func runCommandDepth(line string, depth int) bool {
if !gitOK(DIR, "push") { if !gitOK(DIR, "push") {
break break
} }
sshOK("touch " + cfg.GitPath + "/" + PRJ + ".git") sshOK("touch " + shq(cfg.GitPath+"/"+PRJ+".git"))
if truthy(cfg.Mirror) && cfg.RemoteURL != "" && cfg.RemoteKey != "" { if targets, _ := cfg.mirrorTargets(); truthy(cfg.Mirror) && len(targets) > 0 {
handlePushRemote("") // auto-mirror to the public server handlePushRemote("") // auto-mirror to every configured server
} }
case "pushremote": // mirror the repo to a public git server via its API 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 case "archive": // snapshot the server-side repository into ./archive
if PRJ == "" { if !requireProject() {
errorln("no project selected")
break break
} }
comment := sanitizeComment(strings.Join(fields[1:], " ")) comment := sanitizeComment(strings.Join(fields[1:], " "))
@@ -255,89 +310,104 @@ func runCommandDepth(line string, depth int) bool {
if comment != "" { if comment != "" {
name = PRJ + "_" + z + "_" + 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 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 break
} }
sshOK("rm -rf archive/" + name + ".git") sshOK("rm -rf " + shq("archive/"+name+".git"))
case "init": // create a new repository from the current directory case "init": // create a new repository from the current directory
if PRJ == "" { if !requireProject() {
errorln("no project selected")
break break
} }
if fileExists(DIR + "/push.pl") { if fileExists(DIR + "/push.pl") {
runInDir(DIR, "perl", DIR+"/push.pl") runInDir(DIR, "perl", DIR+"/push.pl")
} }
if REPO == "" || yesno("overwrite existing repository?", false) { // Whether a repository would be destroyed is a property of the *server*,
if !sshOK("rm -rf " + cfg.GitPath + "/" + PRJ + ".git") { // not of this checkout's remote.origin.url — an unlinked project
break // directory says nothing about what is on the other end.
} exists, err := serverEntryExists(".", PRJ+".git")
if !sshOK("mkdir " + cfg.GitPath + "/" + PRJ + ".git;cd " + cfg.GitPath + "/" + PRJ + ".git;git --bare init") { if err != nil {
break errorln("could not reach the git server: " + err.Error())
} 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")
} }
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 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 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 break // ignore filesystem-relative navigation
} }
PRJ = "" PRJ = "" // bare `cd` deselects the project and returns to BASE
if isDir(BASE + "/" + word(words, 1)) { if validProject(arg) && isDir(BASE+"/"+arg) {
PRJ = words[1] PRJ = arg
} }
case "checkout": case "checkout":
if !requireRepo() { if !requireRepo() {
break 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 case "clone": // clone a repository (or archive with -a) from the server
prj := PRJ prj := PRJ
if w := word(words, 1); w != "" { if w := word(words, 1); w != "" {
prj = w prj = w
} }
path := "." if !validProject(prj) {
errorln("invalid repository name: " + prj)
break
}
path, entry := ".", prj+".git"
if opt["a"] { if opt["a"] {
path = "./archive" path, entry = "./archive", prj+".git.tar.gz"
} }
found := 0 exists, err := serverEntryExists(path, entry)
lines, _ := sshOut("/bin/ls " + path) if err != nil {
for _, ln := range lines { errorln("could not reach the git server: " + err.Error())
t := strings.TrimSpace(ln) break
if opt["a"] {
if t == prj+".git.tar.gz" {
found++
}
} else if t == prj+".git" {
found++
}
} }
if found != 1 { if !exists {
errorln("repository not found, try 'list [-a]'") errorln("repository not found, try 'list [-a]'")
break break
} }
@@ -353,13 +423,13 @@ func runCommandDepth(line string, depth int) bool {
break break
} }
} else { } else {
if !sshOK("cd archive;tar xvzf " + prj + ".git.tar.gz") { if !sshOK("cd archive && tar xvzf " + shq(prj+".git.tar.gz")) {
break break
} }
if !gitOK(BASE, "clone", URL+"/archive/"+prj+".git") { if !gitOK(BASE, "clone", URL+"/archive/"+prj+".git") {
break break
} }
sshOK("rm -rf archive/" + prj + ".git") sshOK("rm -rf " + shq("archive/"+prj+".git"))
} }
if isDir(BASE + "/" + prj) { if isDir(BASE + "/" + prj) {
PRJ = prj PRJ = prj
@@ -370,7 +440,11 @@ func runCommandDepth(line string, depth int) bool {
if opt["a"] { if opt["a"] {
path = "./archive" 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 { for _, ln := range lines {
t := strings.TrimSpace(ln) t := strings.TrimSpace(ln)
if m := gitDirRe.FindStringSubmatch(t); m != nil { if m := gitDirRe.FindStringSubmatch(t); m != nil {
@@ -385,7 +459,7 @@ func runCommandDepth(line string, depth int) bool {
prj = w prj = w
} }
d := BASE + "/" + prj d := BASE + "/" + prj
if !isDir(d) { if !validProject(prj) || !isDir(d) {
errorln("not found") errorln("not found")
break break
} }
@@ -400,16 +474,14 @@ func runCommandDepth(line string, depth int) bool {
} }
} }
} }
if xws != "" && xprj != "" { // prefer the workspace over the project; fall back to the editor unless
xprj = "" // prefer the workspace // one of them is really openable (a name match on a plain file is not).
} switch {
if xws != "" && isDir(d+"/"+xws) { case xws != "" && isDir(d+"/"+xws):
runInDir(d, "open", xws) runInDir(d, "open", xws)
} case xprj != "" && isDir(d+"/"+xprj):
if xprj != "" && isDir(d+"/"+xprj) {
runInDir(d, "open", xprj) runInDir(d, "open", xprj)
} default:
if xws == "" && xprj == "" {
editor := cfg.Editor editor := cfg.Editor
if editor == "" { if editor == "" {
editor = "coda" editor = "coda"
@@ -421,9 +493,15 @@ func runCommandDepth(line string, depth int) bool {
} }
case "count": // count source lines in the project case "count": // count source lines in the project
if !requireProject() {
break
}
countLines(DIR) countLines(DIR)
case "tag": // manage tags case "tag": // manage tags
if !requireRepo() {
break
}
sub := word(words, 1) sub := word(words, 1)
switch { switch {
case sub == "add" && word(words, 2) != "": case sub == "add" && word(words, 2) != "":
@@ -603,11 +681,11 @@ const gitignore = `.DS_Store
` `
var helpItems = []struct{ cmd, desc string }{ var helpItems = []struct{ cmd, desc string }{
{"cd [project]", "change project"}, {"cd [project]", "change project (no argument: back to the base)"},
{"open [project]", "open project"}, {"open [project]", "open project"},
{"init", "make new repository from current directory"}, {"init", "make new repository from current directory"},
{"push [comment]", "push changes to git server"}, {"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"}, {"pull", "pull changes from git server"},
{"fetch", "fetch changes from git server"}, {"fetch", "fetch changes from git server"},
{"status [-a]", "short git status (-a: overview of all projects)"}, {"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"}, {"tag delete <tag>", "delete tag"},
{"alias [name [cmd]]", "list, show or define an alias ($1..$N, $* args)"}, {"alias [name [cmd]]", "list, show or define an alias ($1..$N, $* args)"},
{"unalias <name>", "remove an alias"}, {"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"}, {"!<command>", "run <command> in the shell"},
{"quit", "exit mgsh"}, {"quit", "exit mgsh"},
} }
@@ -640,7 +719,7 @@ func help() {
fmt.Printf("%s v%s %s, builtin commands:\n\n", fmt.Printf("%s v%s %s, builtin commands:\n\n",
col(cBold+cWhite, "mgsh (git shell)"), col(cYellow, VERSION), INFO) col(cBold+cWhite, "mgsh (git shell)"), col(cYellow, VERSION), INFO)
for _, it := range helpItems { 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() fmt.Println()
} }
+28 -8
View File
@@ -25,7 +25,7 @@ func completer() *readline.PrefixCompleter {
readline.PcItem("show", readline.PcItemDynamic(dynServerRepos)), readline.PcItem("show", readline.PcItemDynamic(dynServerRepos)),
readline.PcItem("list", readline.PcItem("-a")), readline.PcItem("list", readline.PcItem("-a")),
readline.PcItem("push"), readline.PcItem("push"),
readline.PcItem("pushremote"), readline.PcItem("pushremote", readline.PcItemDynamic(dynRemoteNames)),
readline.PcItem("pull"), readline.PcItem("pull"),
readline.PcItem("fetch"), readline.PcItem("fetch"),
readline.PcItem("status", readline.PcItem("-a")), readline.PcItem("status", readline.PcItem("-a")),
@@ -46,6 +46,7 @@ func completer() *readline.PrefixCompleter {
), ),
readline.PcItem("alias", readline.PcItemDynamic(dynAliasNames)), readline.PcItem("alias", readline.PcItemDynamic(dynAliasNames)),
readline.PcItem("unalias", readline.PcItemDynamic(dynAliasNames)), readline.PcItem("unalias", readline.PcItemDynamic(dynAliasNames)),
readline.PcItem("config", readline.PcItem("-k")),
readline.PcItem("rescan"), readline.PcItem("rescan"),
readline.PcItem("help"), readline.PcItem("help"),
readline.PcItem("quit"), readline.PcItem("quit"),
@@ -80,22 +81,30 @@ func fetchServerRepos() {
if serverFetched { if serverFetched {
return return
} }
serverFetched = true lines, err := sshOut("/bin/ls .")
if lines, err := sshOut("/bin/ls ."); err == nil { if err != nil {
for _, ln := range lines { // a transient failure (server down, no network) must not cache an
if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil { // empty list for the rest of the session — the next Tab tries again
serverRepos = append(serverRepos, m[1]) return
} }
var repos []string
for _, ln := range lines {
if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil {
repos = append(repos, m[1])
} }
} }
// a missing ./archive is a permanent, unremarkable state: still cache
var archives []string
if lines, err := sshOut("/bin/ls archive"); err == nil { if lines, err := sshOut("/bin/ls archive"); err == nil {
for _, ln := range lines { for _, ln := range lines {
t := strings.TrimSpace(ln) t := strings.TrimSpace(ln)
if strings.HasSuffix(t, ".git.tar.gz") { if strings.HasSuffix(t, ".git.tar.gz") {
serverArchives = append(serverArchives, strings.TrimSuffix(t, ".git.tar.gz")) archives = append(archives, strings.TrimSuffix(t, ".git.tar.gz"))
} }
} }
} }
serverRepos, serverArchives = repos, archives
serverFetched = true
} }
// rescanServer clears the cached server listing so the next completion (or use) // rescanServer clears the cached server listing so the next completion (or use)
@@ -109,6 +118,17 @@ func rescanServer() {
func dynServerRepos(string) []string { fetchServerRepos(); return serverRepos } func dynServerRepos(string) []string { fetchServerRepos(); return serverRepos }
func dynServerArchives(string) []string { fetchServerRepos(); return serverArchives } func dynServerArchives(string) []string { fetchServerRepos(); return serverArchives }
// dynRemoteNames offers the configured mirror targets as `@name` selectors for
// `pushremote`, resolved against the active project's configuration.
func dynRemoteNames(string) []string {
targets, _ := cfg.mirrorTargets()
var out []string
for _, t := range targets {
out = append(out, "@"+t.Name)
}
return out
}
// dynBranches / dynTags list the active project's local branches / tags. // dynBranches / dynTags list the active project's local branches / tags.
func dynBranches(string) []string { func dynBranches(string) []string {
if !isDir(DIR + "/.git") { if !isDir(DIR + "/.git") {
+280 -11
View File
@@ -2,22 +2,33 @@ package main
import ( import (
"bufio" "bufio"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"sort"
"strings" "strings"
) )
// Config holds all externally configurable settings. It is read entirely from // configMode is the permission mask for ~/.mgshrc. The file holds the
// ~/.mgshrc and then overlaid with MGSH_* environment variables — there are no // 'remotekey' API token, so it must not be readable by other local users.
// built-in defaults. Missing required settings are a fatal error (see const configMode = 0o600
// missingRequired).
// projectRC is the per-project configuration file, read from the active
// project directory and overlaid on the global settings.
const projectRC = ".mgshrc"
// Config holds all externally configurable settings. It is read from ~/.mgshrc,
// overlaid with the active project's own .mgshrc and then with MGSH_*
// environment variables — there are no built-in defaults. Missing required
// settings are a fatal error (see missingRequired).
type Config struct { type Config struct {
Base string // project base directory Base string // project base directory (global only)
GitHost string // git server host GitHost string // git server host
GitPort string // ssh port GitPort string // ssh port
GitUser string // ssh user GitUser string // ssh user
GitPath string // remote path holding the bare repos GitPath string // remote path holding the bare repos
GitKey string // ssh key name (informational) GitKey string // ssh identity for the git server ("" = ssh defaults)
GitName string // git user.name to set globally ("" = leave alone) GitName string // git user.name to set globally ("" = leave alone)
GitEmail string // git user.email to set globally ("" = leave alone) GitEmail string // git user.email to set globally ("" = leave alone)
PushDefault string // git push.default to set globally ("" = leave alone) PushDefault string // git push.default to set globally ("" = leave alone)
@@ -27,6 +38,74 @@ type Config struct {
RemoteType string // "gitea"|"github"|"gitlab" (auto-detected when empty) RemoteType string // "gitea"|"github"|"gitlab" (auto-detected when empty)
RemoteVis string // visibility of created repos: "private" (default)|"public" RemoteVis string // visibility of created repos: "private" (default)|"public"
Mirror string // truthy -> `push` also mirrors via `pushremote` Mirror string // truthy -> `push` also mirrors via `pushremote`
Remotes []RemoteTarget
RemoteNames string // "remotes": explicit, ordered subset of targets to use
}
// RemoteTarget is one public mirror server for `pushremote`, configured as a
// `remote.<name>.<field>` block. Name doubles as the git remote name created in
// the repository, so several targets can coexist side by side.
type RemoteTarget struct {
Name string
URL string
Key string
Type string // "gitea"|"github"|"gitlab" (auto-detected when empty)
Vis string // "private" (default) | "public"
}
// legacyRemoteName is the target name for the flat remoteurl/remotekey pair,
// matching the git remote that earlier versions created.
const legacyRemoteName = "public"
// mirrorTargets returns the usable mirror targets in configured order, plus the
// names of targets that are defined but unusable (missing url or key) so the
// caller can complain about them instead of silently skipping.
func (c Config) mirrorTargets() (usable []RemoteTarget, incomplete []string) {
var all []RemoteTarget
if c.RemoteURL != "" || c.RemoteKey != "" {
all = append(all, RemoteTarget{
Name: legacyRemoteName, URL: c.RemoteURL, Key: c.RemoteKey,
Type: c.RemoteType, Vis: c.RemoteVis,
})
}
all = append(all, c.Remotes...)
// `remotes = a, b` narrows and orders the set — a project .mgshrc uses it
// to mirror to only some of the globally configured servers.
if sel := splitList(c.RemoteNames); len(sel) > 0 {
var picked []RemoteTarget
for _, n := range sel {
for _, t := range all {
if strings.EqualFold(t.Name, n) {
picked = append(picked, t)
break
}
}
}
all = picked
}
for _, t := range all {
if t.URL == "" || t.Key == "" {
incomplete = append(incomplete, t.Name)
continue
}
usable = append(usable, t)
}
return usable, incomplete
}
// splitList splits a comma- or whitespace-separated setting into its items.
func splitList(s string) []string {
var out []string
for _, f := range strings.FieldsFunc(s, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t'
}) {
if f != "" {
out = append(out, f)
}
}
return out
} }
// requiredKeys lists the settings mgsh cannot run without. // requiredKeys lists the settings mgsh cannot run without.
@@ -64,12 +143,120 @@ func loadConfig() Config {
} }
var c Config var c Config
if data, err := os.ReadFile(path); err == nil { if data, err := os.ReadFile(path); err == nil {
applyConfig(&c, parseConfig(string(data))) m := parseConfig(string(data))
applyConfig(&c, m)
warnConfigPerms(path, m)
} }
applyEnv(&c) applyEnv(&c)
return c return c
} }
// projectGlobalOnly lists settings a project-level .mgshrc must not change:
// `base` decides where projects live in the first place, and the git identity
// keys are written to the user's *global* git config at startup — applying
// those per project would rewrite ~/.gitconfig on every `cd`.
var projectGlobalOnly = []string{"base", "gitname", "gitemail", "pushdefault"}
// resolveConfig returns the effective configuration for a project directory:
// the global settings, overlaid with the project's own .mgshrc, with MGSH_*
// applied last so an explicit environment override still wins. dir may be ""
// (no project active), which yields the global configuration unchanged.
func resolveConfig(base Config, dir string) Config {
c := base
// Remotes is a slice: copy it, or a project overlay would write through the
// shared backing array into the global configuration.
c.Remotes = append([]RemoteTarget(nil), base.Remotes...)
if dir == "" {
return c
}
path := filepath.Join(dir, projectRC)
data, err := os.ReadFile(path)
if err != nil {
return c
}
m := parseConfig(string(data))
var ignored []string
for _, k := range projectGlobalOnly {
if _, ok := m[k]; ok {
ignored = append(ignored, k)
delete(m, k)
}
}
if len(ignored) > 0 {
errorln(fmt.Sprintf("%s: ignoring global-only settings: %s",
path, strings.Join(ignored, ", ")))
}
applyConfig(&c, m)
applyEnv(&c)
warnConfigPerms(path, m)
warnConfigTracked(dir, m)
return c
}
// hasSecret reports whether a parsed config assigns an API token.
func hasSecret(m map[string]string) bool {
if m["remotekey"] != "" {
return true
}
for k, v := range m {
if v != "" && strings.HasSuffix(k, ".key") && remoteFieldRe.MatchString(k) {
return true
}
}
return false
}
// warnConfigPerms complains when a config file holding an API token is readable
// by anyone else. New files are created 0600, but a file written by an earlier
// version — or by hand — is not silently re-chmodded behind the user's back.
func warnConfigPerms(path string, m map[string]string) {
if !hasSecret(m) {
return
}
fi, err := os.Stat(path)
if err != nil || fi.Mode().Perm()&0o077 == 0 {
return
}
errorln(fmt.Sprintf("warning: %s holds an API token but is mode %04o — run: chmod 600 %s",
path, fi.Mode().Perm(), path))
}
// warnConfigTracked complains when a project .mgshrc holds an API token and git
// would happily commit it. A project config is a normal file in the working
// tree and `push` commits everything, so this is an easy way to publish a token
// by accident — including on the very first commit made by `init`, which is why
// a project without a repository yet is checked too.
func warnConfigTracked(dir string, m map[string]string) {
if !hasSecret(m) || projectRCIgnored(dir) {
return
}
errorln("warning: " + filepath.Join(dir, projectRC) +
" holds an API token and is not git-ignored — add it to .gitignore")
}
// projectRCIgnored reports whether git would leave the project config out of a
// commit. In a repository git itself answers; before `init` there is no
// repository yet, so the .gitignore that init would use is read directly.
func projectRCIgnored(dir string) bool {
if isDir(dir + "/.git") {
return runQuiet(dir, "git", "check-ignore", "-q", projectRC) == nil
}
data, err := os.ReadFile(filepath.Join(dir, ".gitignore"))
if err != nil {
return false
}
for _, ln := range strings.Split(string(data), "\n") {
switch strings.TrimSpace(ln) {
case projectRC, "/" + projectRC:
return true
}
}
return false
}
// writeConfigTemplate creates a blank, annotated ~/.mgshrc for the user to fill // writeConfigTemplate creates a blank, annotated ~/.mgshrc for the user to fill
// in, migrating any aliases from the legacy ~/.mgsh_aliases file. It writes no // in, migrating any aliases from the legacy ~/.mgsh_aliases file. It writes no
// real values — mgsh has no built-in configuration. // real values — mgsh has no built-in configuration.
@@ -89,14 +276,24 @@ func writeConfigTemplate(path string) {
b.WriteString("# gitname = Your Name\n") b.WriteString("# gitname = Your Name\n")
b.WriteString("# gitemail = you@example.com\n") b.WriteString("# gitemail = you@example.com\n")
b.WriteString("# pushdefault = matching\n") b.WriteString("# pushdefault = matching\n")
b.WriteString("# editor = code\n") b.WriteString("# editor = code\n\n")
b.WriteString("# --- public mirrors for `pushremote` ---\n")
b.WriteString("# One block per server; `pushremote` pushes to all of them,\n")
b.WriteString("# `pushremote @hub` to a single one.\n")
b.WriteString("# remote.hub.url = https://github.com\n")
b.WriteString("# remote.hub.key = <personal-access-token>\n")
b.WriteString("# remote.hub.visibility = public\n")
b.WriteString("# remotes = hub # optional: restrict/order the set\n")
b.WriteString("# mirror = true # `push` also mirrors\n\n")
b.WriteString("# A project may override any of these (except base and the git\n")
b.WriteString("# identity) in its own <project>/.mgshrc.\n")
legacy := readLegacyAliases() legacy := readLegacyAliases()
if blk := aliasBlock(legacy); blk != "" { if blk := aliasBlock(legacy); blk != "" {
b.WriteString("\n" + blk) b.WriteString("\n" + blk)
} }
if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil { if err := os.WriteFile(path, []byte(b.String()), configMode); err != nil {
errorln("could not create " + path + ": " + err.Error()) errorln("could not create " + path + ": " + err.Error())
return return
} }
@@ -124,12 +321,33 @@ func parseConfig(s string) map[string]string {
continue continue
} }
key := strings.ToLower(strings.TrimSpace(line[:i])) key := strings.ToLower(strings.TrimSpace(line[:i]))
val := strings.Trim(strings.TrimSpace(line[i+1:]), "\"'") val := stripInlineComment(strings.TrimSpace(line[i+1:]))
m[key] = val m[key] = strings.Trim(val, "\"'")
} }
return m return m
} }
// stripInlineComment removes a trailing `#` comment from a config value, as
// documented in the README and the generated template. The '#' must follow
// whitespace, so a value may still contain a literal '#' (an API token, a URL
// fragment). A quoted value is taken verbatim up to its closing quote.
func stripInlineComment(v string) string {
if strings.HasPrefix(v, "#") {
return ""
}
if len(v) > 1 && (v[0] == '"' || v[0] == '\'') {
if j := strings.IndexByte(v[1:], v[0]); j >= 0 {
return v[:j+2]
}
}
for i := 1; i < len(v); i++ {
if v[i] == '#' && (v[i-1] == ' ' || v[i-1] == '\t') {
return strings.TrimRight(v[:i], " \t")
}
}
return v
}
func applyConfig(c *Config, m map[string]string) { func applyConfig(c *Config, m map[string]string) {
set := func(key string, dst *string) { set := func(key string, dst *string) {
if v, ok := m[key]; ok && v != "" { if v, ok := m[key]; ok && v != "" {
@@ -150,7 +368,57 @@ func applyConfig(c *Config, m map[string]string) {
set("remotekey", &c.RemoteKey) set("remotekey", &c.RemoteKey)
set("remotetype", &c.RemoteType) set("remotetype", &c.RemoteType)
set("remotevisibility", &c.RemoteVis) set("remotevisibility", &c.RemoteVis)
set("remotes", &c.RemoteNames)
set("mirror", &c.Mirror) set("mirror", &c.Mirror)
applyRemoteTargets(c, m)
}
// remoteFieldRe matches a named mirror target setting: remote.<name>.<field>.
var remoteFieldRe = regexp.MustCompile(`^remote\.([a-z0-9_.-]+)\.(url|key|type|visibility)$`)
// applyRemoteTargets merges `remote.<name>.<field>` settings into c.Remotes.
// An already known target is updated field by field, so a project .mgshrc can
// override just the visibility of a globally configured server. New targets are
// appended in key order, which keeps the push order deterministic.
func applyRemoteTargets(c *Config, m map[string]string) {
keys := make([]string, 0, len(m))
for k := range m {
if remoteFieldRe.MatchString(k) {
keys = append(keys, k)
}
}
sort.Strings(keys)
for _, k := range keys {
v := m[k]
if v == "" {
continue
}
f := remoteFieldRe.FindStringSubmatch(k)
t := &c.Remotes[c.remoteIndex(f[1])]
switch f[2] {
case "url":
t.URL = v
case "key":
t.Key = v
case "type":
t.Type = v
case "visibility":
t.Vis = v
}
}
}
// remoteIndex returns the position of the named target, appending an empty one
// when it is not there yet.
func (c *Config) remoteIndex(name string) int {
for i := range c.Remotes {
if c.Remotes[i].Name == name {
return i
}
}
c.Remotes = append(c.Remotes, RemoteTarget{Name: name})
return len(c.Remotes) - 1
} }
func applyEnv(c *Config) { func applyEnv(c *Config) {
@@ -173,5 +441,6 @@ func applyEnv(c *Config) {
env("MGSH_REMOTEKEY", &c.RemoteKey) env("MGSH_REMOTEKEY", &c.RemoteKey)
env("MGSH_REMOTETYPE", &c.RemoteType) env("MGSH_REMOTETYPE", &c.RemoteType)
env("MGSH_REMOTEVISIBILITY", &c.RemoteVis) env("MGSH_REMOTEVISIBILITY", &c.RemoteVis)
env("MGSH_REMOTES", &c.RemoteNames)
env("MGSH_MIRROR", &c.Mirror) env("MGSH_MIRROR", &c.Mirror)
} }
+87 -3
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
) )
@@ -18,8 +19,66 @@ func runInDir(dir, name string, args ...string) error {
return c.Run() return c.Run()
} }
// runQuiet runs a command in dir with its output discarded, for probes whose
// exit status is the only interesting part.
func runQuiet(dir, name string, args ...string) error {
c := exec.Command(name, args...)
c.Dir = dir
return c.Run()
}
// sshKeyPath returns the identity file to authenticate with at the git server,
// or "" when no 'gitkey' is configured. A bare name is looked up in ~/.ssh, a
// path (absolute or ~/-relative) is used as given.
func sshKeyPath() string {
k := strings.TrimSpace(cfg.GitKey)
if k == "" {
return ""
}
home, _ := os.UserHomeDir()
switch {
case strings.HasPrefix(k, "~/"):
return filepath.Join(home, k[2:])
case strings.ContainsRune(k, '/'):
return k
default:
return filepath.Join(home, ".ssh", k)
}
}
// sshArgs prefixes the ssh options shared by every connection to the git
// server: the configured port and, when set, the identity file.
func sshArgs(rest ...string) []string {
args := []string{"-p", cfg.GitPort}
if k := sshKeyPath(); k != "" {
args = append(args, "-i", k)
}
return append(args, rest...)
}
// gitEnv is the environment for a git subprocess: nil (inherit) unless a
// 'gitkey' is configured, in which case git's ssh transport is pointed at it.
// Set per command instead of in mgsh's own environment, so a `!git ...` shell
// escape keeps whatever the user's shell would normally do.
func gitEnv() []string {
k := sshKeyPath()
if k == "" {
return nil
}
return append(os.Environ(), "GIT_SSH_COMMAND=ssh -i "+shq(k))
}
// git runs a git command with inherited stdio, optionally in dir.
func git(dir string, args ...string) error { func git(dir string, args ...string) error {
return runInDir(dir, "git", args...) c := exec.Command("git", args...)
if dir != "" {
c.Dir = dir
}
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
c.Env = gitEnv()
return c.Run()
} }
// gitOK runs git and, on failure, prints a red summary. Returns success. // gitOK runs git and, on failure, prints a red summary. Returns success.
@@ -36,13 +95,22 @@ func gitCapture(dir string, args ...string) (string, error) {
if dir != "" { if dir != "" {
c.Dir = dir c.Dir = dir
} }
c.Env = gitEnv()
out, err := c.Output() out, err := c.Output()
return string(out), err return string(out), err
} }
// shq quotes s for interpolation into a remote /bin/sh command line. Every
// remote command is a single string handed to the login shell, so any project
// name or configured path reaching it must go through here — otherwise a space
// splits one argument into two and a backtick runs on the server.
func shq(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// ssh runs a single remote command over ssh with inherited stdio. // ssh runs a single remote command over ssh with inherited stdio.
func ssh(remote string) error { func ssh(remote string) error {
return runInDir("", "ssh", "-p", cfg.GitPort, cfg.GitUser+"@"+cfg.GitHost, remote) return runInDir("", "ssh", sshArgs(cfg.GitUser+"@"+cfg.GitHost, remote)...)
} }
// sshOK runs a remote command and, on failure, prints a red summary. // sshOK runs a remote command and, on failure, prints a red summary.
@@ -56,7 +124,7 @@ func sshOK(remote string) bool {
// sshOut runs a remote command and returns its stdout split into lines. // sshOut runs a remote command and returns its stdout split into lines.
func sshOut(remote string) ([]string, error) { func sshOut(remote string) ([]string, error) {
c := exec.Command("ssh", "-p", cfg.GitPort, cfg.GitUser+"@"+cfg.GitHost, remote) c := exec.Command("ssh", sshArgs(cfg.GitUser+"@"+cfg.GitHost, remote)...)
c.Stderr = os.Stderr c.Stderr = os.Stderr
out, err := c.Output() out, err := c.Output()
lines := strings.Split(string(out), "\n") lines := strings.Split(string(out), "\n")
@@ -66,6 +134,22 @@ func sshOut(remote string) ([]string, error) {
return lines, err return lines, err
} }
// serverEntryExists reports whether entry is present in the remote directory
// path (relative to the git user's home). The error is returned rather than
// folded into the bool so a failed lookup is never mistaken for "not there".
func serverEntryExists(path, entry string) (bool, error) {
lines, err := sshOut("/bin/ls " + shq(path))
if err != nil {
return false, err
}
for _, ln := range lines {
if strings.TrimSpace(ln) == entry {
return true, nil
}
}
return false, nil
}
// forwardShell runs a line via /bin/sh -c in the active project directory. // forwardShell runs a line via /bin/sh -c in the active project directory.
func forwardShell(line string) { func forwardShell(line string) {
runInDir(DIR, "/bin/sh", "-c", line) runInDir(DIR, "/bin/sh", "-c", line)
+29 -3
View File
@@ -5,6 +5,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"github.com/chzyer/readline"
) )
// yesno asks a y/n question with a default. Reads a single keypress. // yesno asks a y/n question with a default. Reads a single keypress.
@@ -17,18 +19,28 @@ func yesno(prompt string, def bool) bool {
if ans == "" { if ans == "" {
return def return def
} }
return ans == "y" || ans == "yes" return ans == "y"
} }
// getkey reads a single keypress from the terminal without echo. It reads a // getkey reads a single keypress from the terminal without echo. It reads a
// single byte directly from stdin; readline is not reading at this point (we // single byte directly from stdin; readline is not reading at this point (we
// are inside command execution), so there is no reader to desync with. // are inside command execution), so there is no reader to desync with.
//
// Anything else already typed on the same line is discarded: answering "yes"
// to a y/n prompt must not leave "es\n" queued for the next readline call,
// where it would come back as a bogus command.
func getkey(prompt string) string { func getkey(prompt string) string {
fmt.Print(prompt) fmt.Print(prompt)
stty("-icanon", "-echo") tty := readline.IsTerminal(int(os.Stdin.Fd()))
if tty {
stty("-icanon", "-echo")
}
var buf [1]byte var buf [1]byte
n, err := os.Stdin.Read(buf[:]) n, err := os.Stdin.Read(buf[:])
stty("icanon", "echo") if tty {
drainTTY()
stty("icanon", "echo")
}
key := "" key := ""
if err == nil && n > 0 { if err == nil && n > 0 {
key = strings.Trim(string(buf[:n]), "\r\n\t") key = strings.Trim(string(buf[:n]), "\r\n\t")
@@ -37,6 +49,20 @@ func getkey(prompt string) string {
return key return key
} }
// drainTTY discards input already queued on the terminal. `min 0 time 0` makes
// a read return whatever is buffered without waiting, so this cannot block when
// nothing is pending.
func drainTTY() {
stty("-icanon", "-echo", "min", "0", "time", "0")
buf := make([]byte, 256)
for {
n, err := os.Stdin.Read(buf)
if err != nil || n == 0 {
return
}
}
}
func stty(args ...string) { func stty(args ...string) {
c := exec.Command("stty", args...) c := exec.Command("stty", args...)
c.Stdin = os.Stdin c.Stdin = os.Stdin
+127 -38
View File
@@ -28,15 +28,16 @@ const INFO = "mwx'2026"
// Global runtime state. // Global runtime state.
var ( var (
cfg Config // resolved configuration baseCfg Config // global configuration: ~/.mgshrc + MGSH_*
BASE string // project base directory (cfg.Base) cfg Config // effective configuration: baseCfg + the project's .mgshrc
cfgPRJ string // project whose .mgshrc is currently applied
cfgFresh bool // whether cfg has been resolved at least once
BASE string // project base directory (baseCfg.Base)
URL string // ssh:// clone/push URL URL string // ssh:// clone/push URL
PRJ string // current project (may be empty) PRJ string // current project (may be empty)
DIR string // current working directory (BASE or BASE/PRJ) DIR string // current working directory (BASE or BASE/PRJ)
IP string // local IP, embedded into the initial commit message
HOST string // short hostname HOST string // short hostname
USER string // current user name USER string // current user name
REPO string // repository name parsed from remote.origin.url
BPLSTATE string // "/OFF" if build.pl symlink points at a *.off file BPLSTATE string // "/OFF" if build.pl symlink points at a *.off file
BRANCH string // current git branch of the active project (if any) BRANCH string // current git branch of the active project (if any)
DIRTY bool // whether the active project has uncommitted changes DIRTY bool // whether the active project has uncommitted changes
@@ -50,7 +51,7 @@ func main() {
fmt.Println(banner()) fmt.Println(banner())
// preset project from the first argument (interactive launch: `mgsh myproj`) // preset project from the first argument (interactive launch: `mgsh myproj`)
if len(os.Args) > 1 && isDir(BASE+"/"+os.Args[1]) { if len(os.Args) > 1 && validProject(os.Args[1]) && isDir(BASE+"/"+os.Args[1]) {
PRJ = os.Args[1] PRJ = os.Args[1]
} }
@@ -61,11 +62,7 @@ func main() {
return return
} }
if clCmd != 0 { if clCmd != 0 {
pwd, _ := os.Getwd() PRJ = projectFromCwd()
PRJ = ""
if strings.HasPrefix(pwd, BASE+"/") {
PRJ = pwd[len(BASE)+1:]
}
if clCmd == 2 { if clCmd == 2 {
cmdline = strings.TrimSpace(cmdline + " " + PRJ) cmdline = strings.TrimSpace(cmdline + " " + PRJ)
} }
@@ -106,6 +103,9 @@ func runInteractive() {
continue continue
} else if err == io.EOF { // Ctrl-D } else if err == io.EOF { // Ctrl-D
break break
} else if err != nil { // detached/broken terminal: don't spin
fmt.Fprintln(os.Stderr, err)
break
} }
if !runCommand(strings.TrimSpace(line)) { if !runCommand(strings.TrimSpace(line)) {
break break
@@ -113,6 +113,25 @@ func runInteractive() {
} }
} }
// projectFromCwd derives the active project from the working directory: the
// first path element below BASE, so running `mgsh push` from deep inside a
// project still addresses the project itself and not the subdirectory.
// Returns "" when the working directory is outside BASE.
func projectFromCwd() string {
pwd, err := os.Getwd()
if err != nil || !strings.HasPrefix(pwd, BASE+"/") {
return ""
}
rel := pwd[len(BASE)+1:]
if i := strings.IndexByte(rel, '/'); i >= 0 {
rel = rel[:i]
}
if !validProject(rel) {
return ""
}
return rel
}
// parseArgs mirrors the original command-line dispatch table. It returns the // parseArgs mirrors the original command-line dispatch table. It returns the
// "CLCMD" class (0 = interactive, 1 = plain, 2 = append project name), the raw // "CLCMD" class (0 = interactive, 1 = plain, 2 = append project name), the raw
// command line, and whether `help` was requested. // command line, and whether `help` was requested.
@@ -128,6 +147,7 @@ func parseArgs() (int, string, bool) {
"clone": 2, "init": 2, "log": 2, "clone": 2, "init": 2, "log": 2,
"push": 1, "pushremote": 1, "list": 1, "tag": 1, "archive": 1, "show": 1, "open": 1, "push": 1, "pushremote": 1, "list": 1, "tag": 1, "archive": 1, "show": 1, "open": 1,
"pull": 1, "fetch": 1, "status": 1, "diff": 1, "overview": 1, "pull": 1, "fetch": 1, "status": 1, "diff": 1, "overview": 1,
"config": 1, "count": 1, "login": 1, "cloneall": 1,
} }
if c, ok := cls[a0]; ok { if c, ok := cls[a0]; ok {
return c, strings.Join(os.Args[1:], " "), false return c, strings.Join(os.Args[1:], " "), false
@@ -136,45 +156,108 @@ func parseArgs() (int, string, bool) {
} }
func setup() { func setup() {
cfg = loadConfig() // the *global* configuration has to stand on its own: mgsh must be usable
if miss := cfg.missingRequired(); len(miss) > 0 { // outside any project, where no project .mgshrc can fill in the blanks.
baseCfg = loadConfig()
if miss := baseCfg.missingRequired(); len(miss) > 0 {
fmt.Fprintf(os.Stderr, "mgsh: not configured — missing required settings: %s\n", strings.Join(miss, ", ")) fmt.Fprintf(os.Stderr, "mgsh: not configured — missing required settings: %s\n", strings.Join(miss, ", "))
fmt.Fprintf(os.Stderr, "edit %s or set the corresponding MGSH_* environment variables, then run mgsh again.\n", configFile()) fmt.Fprintf(os.Stderr, "edit %s or set the corresponding MGSH_* environment variables, then run mgsh again.\n", configFile())
os.Exit(1) os.Exit(1)
} }
BASE = cfg.Base BASE = baseCfg.Base
if !isDir(BASE) { if !isDir(BASE) {
fmt.Fprintf(os.Stderr, "%s not found\n", BASE) fmt.Fprintf(os.Stderr, "%s not found\n", BASE)
os.Exit(1) os.Exit(1)
} }
URL = fmt.Sprintf("ssh://%s@%s:%s%s", cfg.GitUser, cfg.GitHost, cfg.GitPort, cfg.GitPath) applyProjectConfig() // no project yet: cfg = baseCfg, URL from it
IP = resolveIP()
HOST = shortHostname() HOST = shortHostname()
if u, err := user.Current(); err == nil { if u, err := user.Current(); err == nil {
USER = u.Username USER = u.Username
} }
// git identity / settings, from config (defaults preserve prior behavior) applyGlobalGitConfig()
if cfg.GitName != "" {
git("", "config", "--global", "user.name", cfg.GitName)
}
if cfg.GitEmail != "" {
git("", "config", "--global", "user.email", cfg.GitEmail)
}
if cfg.PushDefault != "" {
git("", "config", "--global", "push.default", cfg.PushDefault)
}
loadAliases() loadAliases()
} }
// updateDirState recomputes DIR, the build.pl OFF marker, the current branch / // applyGlobalGitConfig writes the configured git identity to ~/.gitconfig, but
// dirty flag and the parsed repository name for the active project. Run once // only the values that actually differ — every mgsh invocation runs this, and a
// per loop iteration. // plain `mgsh status` has no business rewriting the user's global config. The
// current settings are read in one go rather than one process per key.
func applyGlobalGitConfig() {
want := map[string]string{}
if baseCfg.GitName != "" {
want["user.name"] = baseCfg.GitName
}
if baseCfg.GitEmail != "" {
want["user.email"] = baseCfg.GitEmail
}
if baseCfg.PushDefault != "" {
want["push.default"] = baseCfg.PushDefault
}
if len(want) == 0 {
return
}
have := map[string]string{}
if out, err := gitCapture("", "config", "--global", "--list"); err == nil {
for _, ln := range splitLines(out) {
if i := strings.IndexByte(ln, '='); i > 0 {
have[ln[:i]] = ln[i+1:]
}
}
}
for k, v := range want {
if have[k] != v {
git("", "config", "--global", k, v)
}
}
}
// applyProjectConfig re-resolves cfg whenever the active project changes: the
// project's own .mgshrc overrides the global settings, so a single project can
// live on a different server or mirror to a different place. Resolving once per
// project change rather than once per prompt keeps the REPL loop free of I/O.
func applyProjectConfig() {
if cfgFresh && PRJ == cfgPRJ {
return
}
cfgFresh, cfgPRJ = true, PRJ
dir := ""
if PRJ != "" && isDir(BASE+"/"+PRJ) {
dir = BASE + "/" + PRJ
}
cfg = resolveConfig(baseCfg, dir)
URL = fmt.Sprintf("ssh://%s@%s:%s%s", cfg.GitUser, cfg.GitHost, cfg.GitPort, cfg.GitPath)
}
// reloadConfig re-reads ~/.mgshrc and the project's .mgshrc, so an edit takes
// effect without restarting the shell. An edit that breaks the global config is
// rejected rather than applied — the running shell keeps working.
func reloadConfig() {
fresh := loadConfig()
if miss := fresh.missingRequired(); len(miss) > 0 {
errorln("keeping the previous configuration — " + configFile() +
" is missing: " + strings.Join(miss, ", "))
return
}
if fresh.Base != BASE {
errorln("'base' changed to " + fresh.Base + " — restart mgsh to use it")
fresh.Base = BASE
}
baseCfg = fresh
cfgFresh = false
applyProjectConfig()
}
// updateDirState recomputes DIR, the build.pl OFF marker and the current
// branch / dirty flag for the active project. Run once per loop iteration, so
// it stays deliberately cheap.
func updateDirState() { func updateDirState() {
BRANCH = "" BRANCH = ""
DIRTY = false DIRTY = false
applyProjectConfig()
if PRJ != "" && isDir(BASE+"/"+PRJ) { if PRJ != "" && isDir(BASE+"/"+PRJ) {
DIR = BASE + "/" + PRJ DIR = BASE + "/" + PRJ
@@ -197,13 +280,19 @@ func updateDirState() {
DIR = BASE DIR = BASE
BPLSTATE = "" BPLSTATE = ""
} }
}
// derive REPO from the origin URL: basename without the ".git" suffix.
REPO = "" // originURL returns the active project's own origin URL, or "" when it is not a
if out, err := gitCapture(DIR, "config", "remote.origin.url"); err == nil { // repository or has no origin. `--local` matters: a plain `git config` walks up
remurl := strings.TrimRight(strings.TrimSpace(out), "/") // into an enclosing repository and would report a foreign origin for a project
if remurl != "" { // that has no repository of its own.
REPO = strings.TrimSuffix(filepath.Base(remurl), ".git") func originURL() string {
} if !isDir(DIR + "/.git") {
} return ""
}
out, err := gitCapture(DIR, "config", "--local", "remote.origin.url")
if err != nil {
return ""
}
return strings.TrimSpace(out)
} }
+465
View File
@@ -2,7 +2,9 @@ package main
import ( import (
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"reflect"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -115,6 +117,251 @@ base=/tmp/src
} }
} }
func TestParseConfigInlineComments(t *testing.T) {
rc := `
editor = code # fallback opener for ` + "`open`" + `
mirror = true # ` + "`push`" + ` also mirrors via pushremote
gitport = 22 # ssh port
remotekey = abc#123
remoteurl = "https://git.example.com" # quoted, comment after
gitname = ' Spaced # Name '
gitemail = # value is only a comment
`
m := parseConfig(rc)
checks := map[string]string{
"editor": "code",
"mirror": "true",
"gitport": "22",
"remotekey": "abc#123", // '#' not preceded by space stays part of the value
"remoteurl": "https://git.example.com",
"gitname": " Spaced # Name ",
"gitemail": "",
}
for k, want := range checks {
if m[k] != want {
t.Errorf("parseConfig[%q] = %q, want %q", k, m[k], want)
}
}
if !truthy(m["mirror"]) {
t.Errorf("mirror with a trailing comment must stay truthy, got %q", m["mirror"])
}
}
func TestLsEntry(t *testing.T) {
cases := []struct{ line, suffix, want string }{
// ownership is not assumed: any user/group must list
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"},
{"drwxr-xr-x 7 deploy deploy 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"},
{"drwxr-xr-x 7 mike staff 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"},
{"drwxr-xr-x. 7 git users 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"},
// archives only match the archive suffix, and vice versa
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git.tar.gz", "Sep 28 2016 myproj"},
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git", ""},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git.tar.gz", ""},
// non-entries
{"total 48", ".git", ""},
{"", ".git", ""},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 notes", ".git", ""},
}
for _, c := range cases {
if got := lsEntry(c.line, c.suffix); got != c.want {
t.Errorf("lsEntry(%q, %q) = %q, want %q", c.line, c.suffix, got, c.want)
}
}
}
func TestLsEntrySymlink(t *testing.T) {
// a symlinked bare repo lists its target too — only the link name counts
in := "lrwxrwxrwx 1 git git 14 Sep 28 2016 myproj.git -> /srv/other.git"
if got := lsEntry(in, ".git"); got != "Sep 28 2016 myproj" {
t.Errorf("lsEntry(symlink) = %q, want %q", got, "Sep 28 2016 myproj")
}
// and a symlink to something that is not a repo must not match
if got := lsEntry("lrwxrwxrwx 1 git git 5 Sep 28 2016 notes -> x.git", ".git"); got != "" {
t.Errorf("lsEntry(non-repo symlink) = %q, want empty", got)
}
}
func TestMaskSecret(t *testing.T) {
cases := []struct{ in, want string }{
{"", "(unset)"},
{"ab", "**"},
{"abcd", "****"},
{"abcdef", "ab**ef"},
}
for _, c := range cases {
if got := maskSecret(c.in); got != c.want {
t.Errorf("maskSecret(%q) = %q, want %q", c.in, got, c.want)
}
}
// a real-length token must not leak its middle
tok := strings.Repeat("s3cr3t", 6)
if got := maskSecret(tok); strings.Contains(got, "s3cr3ts3cr3t") || len(got) != len(tok) {
t.Errorf("maskSecret leaked or resized: %q", got)
}
}
func TestSSHKeyPath(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
old := cfg
defer func() { cfg = old }()
cfg = Config{}
if got := sshKeyPath(); got != "" {
t.Errorf("no gitkey should yield no identity, got %q", got)
}
cfg = Config{GitKey: "mgit_rsa"} // bare name -> ~/.ssh
if want := filepath.Join(home, ".ssh", "mgit_rsa"); sshKeyPath() != want {
t.Errorf("sshKeyPath() = %q, want %q", sshKeyPath(), want)
}
cfg = Config{GitKey: "~/keys/id"} // ~/-relative
if want := filepath.Join(home, "keys", "id"); sshKeyPath() != want {
t.Errorf("sshKeyPath() = %q, want %q", sshKeyPath(), want)
}
cfg = Config{GitKey: "/etc/keys/id"} // absolute -> as given
if sshKeyPath() != "/etc/keys/id" {
t.Errorf("sshKeyPath() = %q, want /etc/keys/id", sshKeyPath())
}
// the identity must reach git through the environment, quoted
if env := gitEnv(); len(env) == 0 {
t.Fatal("gitEnv() returned no environment for a configured key")
} else if last := env[len(env)-1]; last != `GIT_SSH_COMMAND=ssh -i '/etc/keys/id'` {
t.Errorf("gitEnv() last entry = %q", last)
}
cfg = Config{}
if gitEnv() != nil {
t.Error("gitEnv() must inherit (nil) when no key is configured")
}
}
func TestProjectRCIgnored(t *testing.T) {
dir := t.TempDir()
// no repository yet: the .gitignore that `init` would use is what counts
if projectRCIgnored(dir) {
t.Error("no .gitignore should not count as ignored")
}
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.o\n/"+projectRC+"\n"), 0644); err != nil {
t.Fatal(err)
}
if !projectRCIgnored(dir) {
t.Error("a .gitignore listing /" + projectRC + " should count as ignored")
}
}
func TestShq(t *testing.T) {
cases := []struct{ in, want string }{
{"myproj", "'myproj'"},
{"my project", "'my project'"},
{"it's", `'it'\''s'`},
{"`rm -rf ~`", "'`rm -rf ~`'"},
{"$(id)", "'$(id)'"},
{"", "''"},
}
for _, c := range cases {
if got := shq(c.in); got != c.want {
t.Errorf("shq(%q) = %q, want %q", c.in, got, c.want)
}
}
// the quoted form must survive a real shell as exactly one argument
out, err := exec.Command("/bin/sh", "-c", "printf '[%s]' "+shq("a b`id`'c")).Output()
if err != nil {
t.Fatal(err)
}
if string(out) != "[a b`id`'c]" {
t.Errorf("shq did not round-trip through /bin/sh: %q", out)
}
}
func TestValidProject(t *testing.T) {
ok := []string{"myproj", "my project", "a.b", "x-1_2"}
bad := []string{"", ".", "..", ".hidden", "foo/bar", "../etc", `foo\bar`}
for _, s := range ok {
if !validProject(s) {
t.Errorf("validProject(%q) = false, want true", s)
}
}
for _, s := range bad {
if validProject(s) {
t.Errorf("validProject(%q) = true, want false", s)
}
}
}
func TestProjectFromCwd(t *testing.T) {
base := t.TempDir()
// t.TempDir may hand back a symlinked path (/var -> /private/var on macOS);
// Getwd reports the resolved one, so compare like for like.
base, err := filepath.EvalSymlinks(base)
if err != nil {
t.Fatal(err)
}
deep := filepath.Join(base, "foo", "src", "lib")
if err := os.MkdirAll(deep, 0755); err != nil {
t.Fatal(err)
}
oldBase, oldWd := BASE, mustGetwd(t)
defer func() { BASE = oldBase; os.Chdir(oldWd) }()
BASE = base
cases := []struct{ dir, want string }{
{deep, "foo"}, // deep inside a project -> the project
{filepath.Join(base, "foo"), "foo"}, // project root
{base, ""}, // BASE itself -> no project
{filepath.Dir(base), ""}, // outside BASE -> no project
}
for _, c := range cases {
if err := os.Chdir(c.dir); err != nil {
t.Fatal(err)
}
if got := projectFromCwd(); got != c.want {
t.Errorf("projectFromCwd() in %s = %q, want %q", c.dir, got, c.want)
}
}
}
func mustGetwd(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
return wd
}
func TestCdCommand(t *testing.T) {
base := t.TempDir()
if err := os.MkdirAll(filepath.Join(base, "notes"), 0755); err != nil {
t.Fatal(err)
}
oldBase, oldPrj := BASE, PRJ
defer func() { BASE = oldBase; PRJ = oldPrj }()
BASE, PRJ = base, ""
// bare `cd` must deselect the project, not panic on a missing argument
PRJ = "notes"
runCommand("cd")
if PRJ != "" {
t.Errorf("bare cd: PRJ = %q, want empty", PRJ)
}
runCommand("cd notes")
if PRJ != "notes" {
t.Errorf("cd notes: PRJ = %q, want notes", PRJ)
}
// a path with a separator would escape BASE and is rejected
runCommand("cd ../etc")
if PRJ != "notes" {
t.Errorf("cd ../etc changed PRJ to %q", PRJ)
}
runCommand("cd nosuchproject")
if PRJ != "" {
t.Errorf("cd to a missing project: PRJ = %q, want empty", PRJ)
}
}
func TestApplyConfig(t *testing.T) { func TestApplyConfig(t *testing.T) {
c := Config{GitName: "Original Name"} c := Config{GitName: "Original Name"}
applyConfig(&c, map[string]string{ applyConfig(&c, map[string]string{
@@ -133,6 +380,224 @@ func TestApplyConfig(t *testing.T) {
} }
} }
func TestRemoteTargetsFromConfig(t *testing.T) {
rc := `
remote.gitea.url = https://git.example.com
remote.gitea.key = tok-gitea
remote.hub.url = https://github.com
remote.hub.key = tok-hub
remote.hub.type = github
remote.hub.visibility = public
remote.broken.url = https://nowhere.example # no key -> unusable
`
var c Config
applyConfig(&c, parseConfig(rc))
usable, incomplete := c.mirrorTargets()
if len(usable) != 2 {
t.Fatalf("mirrorTargets usable = %d, want 2 (%+v)", len(usable), usable)
}
// key order is deterministic: gitea before hub
if usable[0].Name != "gitea" || usable[1].Name != "hub" {
t.Errorf("target order = %q,%q, want gitea,hub", usable[0].Name, usable[1].Name)
}
if usable[1].Type != "github" || usable[1].Vis != "public" {
t.Errorf("hub target = %+v, want type github / visibility public", usable[1])
}
if len(incomplete) != 1 || incomplete[0] != "broken" {
t.Errorf("incomplete = %v, want [broken]", incomplete)
}
}
func TestMirrorTargetsLegacyAndSelection(t *testing.T) {
// the flat remoteurl/remotekey pair stays supported, as target "public"
var c Config
applyConfig(&c, parseConfig("remoteurl = https://git.example.com\nremotekey = tok\n"))
usable, _ := c.mirrorTargets()
if len(usable) != 1 || usable[0].Name != legacyRemoteName {
t.Fatalf("legacy flat config = %+v, want one target named %q", usable, legacyRemoteName)
}
// `remotes` restricts and reorders the set
rc := `
remoteurl = https://git.example.com
remotekey = tok
remote.hub.url = https://github.com
remote.hub.key = tok2
remotes = hub, public
`
var c2 Config
applyConfig(&c2, parseConfig(rc))
usable, _ = c2.mirrorTargets()
if len(usable) != 2 || usable[0].Name != "hub" || usable[1].Name != "public" {
t.Fatalf("remotes selection = %+v, want hub,public", usable)
}
var c3 Config
applyConfig(&c3, parseConfig(rc+"remotes = hub\n"))
usable, _ = c3.mirrorTargets()
if len(usable) != 1 || usable[0].Name != "hub" {
t.Fatalf("narrowed selection = %+v, want only hub", usable)
}
}
func TestParsePushRemoteArgs(t *testing.T) {
cases := []struct {
in string
names []string
desc string
}{
{"", nil, ""},
{"a fix", nil, "a fix"},
{"@hub", []string{"hub"}, ""},
{"@hub a fix", []string{"hub"}, "a fix"},
{"@hub @gitea a fix", []string{"hub", "gitea"}, "a fix"},
{"a fix @hub", nil, "a fix @hub"}, // only leading @words select
{"@", nil, ""},
}
for _, c := range cases {
names, desc := parsePushRemoteArgs(c.in)
if strings.Join(names, ",") != strings.Join(c.names, ",") || desc != c.desc {
t.Errorf("parsePushRemoteArgs(%q) = %v,%q, want %v,%q", c.in, names, desc, c.names, c.desc)
}
}
}
func TestPickRemotes(t *testing.T) {
all := []RemoteTarget{{Name: "gitea"}, {Name: "hub"}}
if got := pickRemotes(all, nil); len(got) != 2 {
t.Errorf("no selection should keep all, got %+v", got)
}
got := pickRemotes(all, []string{"HUB"}) // names are case-insensitive
if len(got) != 1 || got[0].Name != "hub" {
t.Errorf("pickRemotes(HUB) = %+v, want hub", got)
}
if got := pickRemotes(all, []string{"nope"}); len(got) != 0 {
t.Errorf("unknown name should select nothing, got %+v", got)
}
}
func TestResolveProjectConfig(t *testing.T) {
dir := t.TempDir()
global := Config{
Base: "/base", GitHost: "global.example", GitPort: "22", GitUser: "git",
GitPath: "/home/git", GitName: "Global Name", Editor: "vi",
Remotes: []RemoteTarget{{Name: "gitea", URL: "https://gitea.example", Key: "tok"}},
}
// no project file -> unchanged
if got := resolveConfig(global, dir); got.GitHost != "global.example" {
t.Fatalf("without a project file GitHost = %q", got.GitHost)
}
rc := `
githost = project.example
editor = code
base = /somewhere/else
gitname = Project Name
remote.hub.url = https://github.com
remote.hub.key = tok2
remote.gitea.visibility = public
`
if err := os.WriteFile(filepath.Join(dir, projectRC), []byte(rc), 0600); err != nil {
t.Fatal(err)
}
got := resolveConfig(global, dir)
if got.GitHost != "project.example" || got.Editor != "code" {
t.Errorf("project overrides not applied: host=%q editor=%q", got.GitHost, got.Editor)
}
// base and the git identity stay global
if got.Base != "/base" {
t.Errorf("project must not override base, got %q", got.Base)
}
if got.GitName != "Global Name" {
t.Errorf("project must not override gitname, got %q", got.GitName)
}
// a project adds a target and refines a field of a global one
targets, _ := got.mirrorTargets()
if len(targets) != 2 {
t.Fatalf("targets = %+v, want gitea and hub", targets)
}
if targets[0].Name != "gitea" || targets[0].Vis != "public" || targets[0].Key != "tok" {
t.Errorf("gitea target = %+v, want visibility public with the global key", targets[0])
}
if targets[1].Name != "hub" || targets[1].URL != "https://github.com" {
t.Errorf("hub target = %+v", targets[1])
}
// the global configuration must be untouched by the overlay
if global.GitHost != "global.example" || len(global.Remotes) != 1 || global.Remotes[0].Vis != "" {
t.Errorf("resolveConfig mutated the global config: %+v", global)
}
// MGSH_* still wins over the project file
t.Setenv("MGSH_GITHOST", "env.example")
if got := resolveConfig(global, dir); got.GitHost != "env.example" {
t.Errorf("env override lost against project file, got %q", got.GitHost)
}
}
// TestEveryConfigKeyHasEnvOverride keeps the documented settings, the `config`
// command and applyEnv in step: every key mgsh reports must really be
// overridable through its MGSH_* variable.
func TestEveryConfigKeyHasEnvOverride(t *testing.T) {
for _, key := range configKeys() {
probe := "probe-" + key
t.Setenv(envName(key), probe)
var c Config
applyEnv(&c)
if !configHasValue(c, probe) {
t.Errorf("%s does not override the %q setting", envName(key), key)
}
t.Setenv(envName(key), "")
}
}
// configHasValue reports whether any string field of c equals want.
func configHasValue(c Config, want string) bool {
v := reflect.ValueOf(c)
for i := 0; i < v.NumField(); i++ {
if f := v.Field(i); f.Kind() == reflect.String && f.String() == want {
return true
}
}
return false
}
// TestCheckoutForwardsOptions covers the option-stripping trap: mgsh pulls
// `-x` flags out of the word list, so a command that forwards to git has to use
// the raw fields or `checkout -b topic` silently loses its flag.
func TestCheckoutForwardsOptions(t *testing.T) {
base := t.TempDir()
dir := filepath.Join(base, "proj")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"init", "-q"},
{"-c", "user.name=t", "-c", "user.email=t@e", "commit", "-q", "--allow-empty", "-m", "x"},
} {
if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
oldBase, oldPrj, oldDir := BASE, PRJ, DIR
defer func() { BASE, PRJ, DIR = oldBase, oldPrj, oldDir }()
BASE, PRJ, DIR = base, "proj", dir
runCommand("checkout -b topic")
out, err := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").Output()
if err != nil {
t.Fatal(err)
}
if got := strings.TrimSpace(string(out)); got != "topic" {
t.Errorf("after `checkout -b topic` HEAD is %q, want topic", got)
}
}
func TestMissingRequired(t *testing.T) { func TestMissingRequired(t *testing.T) {
full := Config{Base: "/b", GitHost: "h", GitPort: "22", GitUser: "u", GitPath: "/p"} full := Config{Base: "/b", GitHost: "h", GitPort: "22", GitUser: "u", GitPath: "/p"}
if m := full.missingRequired(); len(m) != 0 { if m := full.missingRequired(); len(m) != 0 {
+123 -35
View File
@@ -4,14 +4,28 @@ package main
// git hosting server (Gitea, GitHub or GitLab), creating the repository via the // git hosting server (Gitea, GitHub or GitLab), creating the repository via the
// server's REST API when it does not exist yet. // server's REST API when it does not exist yet.
// //
// Configuration (in ~/.mgshrc or MGSH_* env): // Configuration (in ~/.mgshrc, a project .mgshrc, or MGSH_* env) — either a
// single flat target:
// //
// remoteurl = https://git.example.com base URL of the server // remoteurl = https://git.example.com base URL of the server
// remotekey = <api-token> personal access token // remotekey = <api-token> personal access token
// remotetype = gitea|github|gitlab optional; auto-detected from the URL // remotetype = gitea|github|gitlab optional; auto-detected from the URL
// //
// or any number of named ones, which `pushremote` mirrors to in turn:
//
// remote.gitea.url = https://git.example.com
// remote.gitea.key = <api-token>
// remote.hub.url = https://github.com
// remote.hub.key = <api-token>
// remote.hub.visibility = public
// remotes = gitea, hub optional: restrict/order the set
//
// Each target owns a git remote of the same name in the repository.
//
// The token is used for the API calls and, via an HTTP Basic auth header, for // The token is used for the API calls and, via an HTTP Basic auth header, for
// the git push. It is never written into the repository's git config. // the git push. It is never written into the repository's git config, and it is
// handed to git through the environment rather than the command line so it does
// not show up in the process table.
import ( import (
"bytes" "bytes"
@@ -208,40 +222,105 @@ func (r *remoteAPI) repoWebURL(owner, repo string) string {
return base + "/" + owner + "/" + repo + ".git" return base + "/" + owner + "/" + repo + ".git"
} }
// handlePushRemote implements the `pushremote [description]` command. The // parsePushRemoteArgs splits `pushremote [@name ...] [description]` into the
// description, if given, is set on the repository when it is created. // selected target names and the description. The '@' sigil keeps the two apart:
func handlePushRemote(description string) { // without it a description whose first word happens to name a remote would
// silently push somewhere else.
func parsePushRemoteArgs(args string) (names []string, description string) {
fields := strings.Fields(args)
i := 0
for ; i < len(fields); i++ {
if !strings.HasPrefix(fields[i], "@") {
break
}
if n := strings.TrimPrefix(fields[i], "@"); n != "" {
names = append(names, n)
}
}
return names, strings.Join(fields[i:], " ")
}
// pickRemotes narrows all to the explicitly requested names, complaining about
// any that are not configured. With no names given, all targets are used.
func pickRemotes(all []RemoteTarget, names []string) []RemoteTarget {
if len(names) == 0 {
return all
}
var out []RemoteTarget
seen := map[string]bool{}
for _, n := range names {
found := false
for _, t := range all {
if !strings.EqualFold(t.Name, n) {
continue
}
if !seen[t.Name] { // `@hub @hub` must not push twice
seen[t.Name] = true
out = append(out, t)
}
found = true
break
}
if !found {
errorln("unknown remote: " + n)
}
}
return out
}
// handlePushRemote implements `pushremote [@name ...] [description]`. Without
// a @name it mirrors to every configured target; the description, if given, is
// set on the repository when it is created.
func handlePushRemote(args string) {
if !requireRepo() { if !requireRepo() {
return return
} }
if cfg.RemoteURL == "" || cfg.RemoteKey == "" { names, description := parsePushRemoteArgs(args)
errorln("pushremote needs 'remoteurl' and 'remotekey' in ~/.mgshrc")
return targets, incomplete := cfg.mirrorTargets()
for _, n := range incomplete {
errorln("remote " + n + ": url or key missing — skipped")
} }
repo := PRJ targets = pickRemotes(targets, names)
if repo == "" { if len(targets) == 0 {
repo = REPO if len(names) == 0 { // an unknown @name already reported itself
} errorln("pushremote needs 'remoteurl'/'remotekey' or a 'remote.<name>.*' block in " + configFile())
if repo == "" { }
errorln("cannot determine repository name")
return return
} }
private := !strings.EqualFold(strings.TrimSpace(cfg.RemoteVis), "public") repo := PRJ // requireRepo() guarantees a project, which names the repository
api := newRemoteAPI(cfg.RemoteURL, cfg.RemoteKey, cfg.RemoteType) done := 0
for _, t := range targets {
if pushToRemote(t, repo, description) {
done++
}
}
if len(targets) > 1 {
fmt.Printf("%s %d/%d remotes updated\n", col(cGray, "pushremote:"), done, len(targets))
}
}
// pushToRemote mirrors the active project to one target, creating the
// repository when needed. It returns whether the push succeeded — a server that
// is down must not stop the remaining targets.
func pushToRemote(t RemoteTarget, repo, description string) bool {
private := !strings.EqualFold(strings.TrimSpace(t.Vis), "public")
api := newRemoteAPI(t.URL, t.Key, t.Type)
owner, err := api.authUser() owner, err := api.authUser()
if err != nil { if err != nil {
errorln(err.Error()) errorln(t.Name + ": " + err.Error())
return return false
} }
fmt.Printf("%s %s (as %s)\n", col(cGray, "remote"), col(cCyan, api.url), col(cGreen, owner)) fmt.Printf("%s %s %s (as %s)\n",
col(cGray, "remote"), col(cYellow, t.Name), col(cCyan, api.url), col(cGreen, owner))
exists, err := api.repoExists(owner, repo) exists, err := api.repoExists(owner, repo)
if err != nil { if err != nil {
errorln(err.Error()) errorln(t.Name + ": " + err.Error())
return return false
} }
if exists { if exists {
fmt.Printf("repository %s exists\n", col(cGreen, owner+"/"+repo)) fmt.Printf("repository %s exists\n", col(cGreen, owner+"/"+repo))
@@ -252,39 +331,48 @@ func handlePushRemote(description string) {
} }
fmt.Printf("creating %s repository %s ...\n", vis, col(cGreen, owner+"/"+repo)) fmt.Printf("creating %s repository %s ...\n", vis, col(cGreen, owner+"/"+repo))
if err := api.createRepo(repo, description, private); err != nil { if err := api.createRepo(repo, description, private); err != nil {
errorln(err.Error()) errorln(t.Name + ": " + err.Error())
return return false
} }
} }
// keep a credential-free remote named "public" for convenience // keep a credential-free git remote named after the target
web := api.repoWebURL(owner, repo) web := api.repoWebURL(owner, repo)
if _, err := gitCapture(DIR, "remote", "get-url", "public"); err == nil { if _, err := gitCapture(DIR, "remote", "get-url", t.Name); err == nil {
gitOK(DIR, "remote", "set-url", "public", web) gitOK(DIR, "remote", "set-url", t.Name, web)
} else { } else {
gitOK(DIR, "remote", "add", "public", web) gitOK(DIR, "remote", "add", t.Name, web)
} }
// authenticate the push with a one-shot Basic auth header so the token is // authenticate the push with a one-shot Basic auth header, so the token is
// never persisted in the repository's git config // neither persisted in the repository's git config nor visible in `ps`
header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(owner+":"+api.key)) header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(owner+":"+api.key))
if !gitPushHeader(DIR, "public", header, "--all") { if !gitPushHeader(DIR, t.Name, header, "--all") {
return return false
} }
gitPushHeader(DIR, "public", header, "--tags") gitPushHeader(DIR, t.Name, header, "--tags")
fmt.Println(col(cGreen, "pushed to ") + col(cCyan, web)) fmt.Println(col(cGreen, "pushed to ") + col(cCyan, web))
return true
} }
// gitPushHeader runs `git push <remote> <args...>` with an extra HTTP auth // gitPushHeader runs `git push <remote> <args...>` with an extra HTTP auth
// header, disabling interactive credential prompts. // header, disabling interactive credential prompts.
//
// The header carries the token, so it is passed through GIT_CONFIG_* rather
// than `-c http.extraHeader=...`: a command line is world-readable in the
// process table, an environment block is not.
func gitPushHeader(dir, remote, header string, args ...string) bool { func gitPushHeader(dir, remote, header string, args ...string) bool {
full := append([]string{"-c", "http.extraHeader=" + header, "push", remote}, args...) c := exec.Command("git", append([]string{"push", remote}, args...)...)
c := exec.Command("git", full...)
c.Dir = dir c.Dir = dir
c.Stdin = os.Stdin c.Stdin = os.Stdin
c.Stdout = os.Stdout c.Stdout = os.Stdout
c.Stderr = os.Stderr c.Stderr = os.Stderr
c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") c.Env = append(os.Environ(),
"GIT_TERMINAL_PROMPT=0",
"GIT_CONFIG_COUNT=1",
"GIT_CONFIG_KEY_0=http.extraHeader",
"GIT_CONFIG_VALUE_0="+header,
)
if err := c.Run(); err != nil { if err := c.Run(); err != nil {
errorln("git push " + remote + " " + strings.Join(args, " ") + " failed") errorln("git push " + remote + " " + strings.Join(args, " ") + " failed")
return false return false
+154
View File
@@ -0,0 +1,154 @@
package main
// show_config.go — the `config` command: print the configuration mgsh actually
// resolved, and where it came from. With three layers (~/.mgshrc, the project's
// .mgshrc, MGSH_*) and mirror targets that can be added or narrowed per
// project, "what is in effect right now" is otherwise guesswork.
import (
"fmt"
"os"
"sort"
"strings"
)
// configItem is one reported setting.
type configItem struct{ key, value string }
// showConfig prints the effective configuration, marking every value that does
// not come from the global file with its source.
func showConfig() {
global := configFile()
project := ""
if PRJ != "" && fileExists(DIR+"/"+projectRC) {
project = DIR + "/" + projectRC
}
fmt.Printf("%s %s\n", col(cGray, "global "), col(cCyan, global))
if project != "" {
fmt.Printf("%s %s\n", col(cGray, "project"), col(cCyan, project))
} else if PRJ != "" {
fmt.Printf("%s %s\n", col(cGray, "project"), col(cGray, "no "+projectRC+" in "+PRJ))
}
fmt.Println()
items := []configItem{
{"base", cfg.Base},
{"githost", cfg.GitHost},
{"gitport", cfg.GitPort},
{"gituser", cfg.GitUser},
{"gitpath", cfg.GitPath},
{"gitkey", cfg.GitKey},
{"gitname", cfg.GitName},
{"gitemail", cfg.GitEmail},
{"pushdefault", cfg.PushDefault},
{"editor", cfg.Editor},
{"mirror", cfg.Mirror},
{"remotes", cfg.RemoteNames},
}
// which keys the active project's file actually sets, for the source column
fromProject := map[string]bool{}
if project != "" {
if data, err := os.ReadFile(project); err == nil {
for k := range parseConfig(string(data)) {
fromProject[k] = true
}
}
}
for _, it := range items {
if it.value == "" {
continue
}
src := ""
switch {
case os.Getenv(envName(it.key)) != "":
src = col(cYellow, " (env)")
case fromProject[it.key]:
src = col(cYellow, " ("+projectRC+")")
}
fmt.Printf(" %s%s%s\n", col(cGreen, padRight(it.key, 14)), it.value, src)
}
if k := sshKeyPath(); k != "" {
fmt.Printf(" %s%s\n", col(cGray, padRight("ssh identity", 14)), col(cGray, k))
}
fmt.Printf(" %s%s\n", col(cGray, padRight("clone url", 14)), col(cGray, URL))
// the project's real origin: it can differ from what the current settings
// would produce, e.g. after moving the server or editing a project .mgshrc
if o := originURL(); o != "" {
fmt.Printf(" %s%s\n", col(cGray, padRight("origin", 14)), col(cGray, o))
}
showRemotes()
}
// showRemotes lists the `pushremote` targets in push order, with the tokens
// masked — `config` is the kind of output that ends up pasted into a bug report.
func showRemotes() {
targets, incomplete := cfg.mirrorTargets()
fmt.Println()
if len(targets) == 0 && len(incomplete) == 0 {
fmt.Println(col(cGray, "no pushremote targets configured"))
return
}
fmt.Println(col(cGray, "pushremote targets (in push order):"))
for _, t := range targets {
vis := "private"
if strings.EqualFold(strings.TrimSpace(t.Vis), "public") {
vis = "public"
}
kind := t.Type
if kind == "" {
kind = remoteKindName(detectRemoteKind(t.URL, "")) + " (detected)"
}
fmt.Printf(" %s%s %s\n",
col(cGreen, padRight("@"+t.Name, 14)), t.URL,
col(cGray, kind+", "+vis+", key "+maskSecret(t.Key)))
}
for _, n := range incomplete {
fmt.Printf(" %s%s\n", col(cRed, padRight("@"+n, 14)), col(cRed, "incomplete: url or key missing"))
}
}
// remoteKindName renders a remoteKind for display.
func remoteKindName(k remoteKind) string {
switch k {
case kindGitHub:
return "github"
case kindGitLab:
return "gitlab"
default:
return "gitea"
}
}
// maskSecret reduces a token to a recognisable but useless stub.
func maskSecret(s string) string {
if s == "" {
return "(unset)"
}
if len(s) <= 4 {
return strings.Repeat("*", len(s))
}
return s[:2] + strings.Repeat("*", len(s)-4) + s[len(s)-2:]
}
// envName maps a config key to its MGSH_* environment variable.
func envName(key string) string { return "MGSH_" + strings.ToUpper(key) }
// configKeys lists every flat setting name — that is, every key that also has
// an MGSH_* override. The `remote.<name>.*` targets are a pattern, not a fixed
// set, and are reported separately.
func configKeys() []string {
keys := []string{
"base", "githost", "gitport", "gituser", "gitpath", "gitkey",
"gitname", "gitemail", "pushdefault", "editor",
"remoteurl", "remotekey", "remotetype", "remotevisibility",
"remotes", "mirror",
}
sort.Strings(keys)
return keys
}