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:
@@ -8,3 +8,4 @@
|
|||||||
.TemporaryItems
|
.TemporaryItems
|
||||||
.Trashes
|
.Trashes
|
||||||
mgsh
|
mgsh
|
||||||
|
.mgshrc
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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") {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user