diff --git a/.gitignore b/.gitignore index 529a5b9..b97f152 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ .TemporaryItems .Trashes mgsh +.mgshrc diff --git a/alias.go b/alias.go index dcb3f10..fd7310b 100644 --- a/alias.go +++ b/alias.go @@ -47,7 +47,7 @@ var builtinCmds = map[string]bool{ "pushremote": true, "overview": true, "archive": true, "init": true, "login": true, "cd": true, "checkout": true, "clone": true, "cloneall": 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] } @@ -169,7 +169,9 @@ func saveAliases() { if blk := aliasBlock(aliases); 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()) } } diff --git a/commands.go b/commands.go index 4e62d57..a48f135 100644 --- a/commands.go +++ b/commands.go @@ -12,20 +12,62 @@ import ( ) var ( - optRe = regexp.MustCompile(`^-(\w)$`) - numRe = regexp.MustCompile(`^\d+$`) - lsRepoRe = regexp.MustCompile(`git\s+git\s+\d+\s+(.*)\.git$`) - lsArchRe = regexp.MustCompile(`git\s+git\s+\d+\s+(.*)\.git\.tar\.gz$`) - gitDirRe = regexp.MustCompile(`^(.*)\.git$`) - sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`) - wsRe = regexp.MustCompile(`\s+`) + optRe = regexp.MustCompile(`^-(\w)$`) + numRe = regexp.MustCompile(`^\d+$`) + // a `ls -ltr` long-listing line: mode, link count, owner, group, size, then + // the date columns and the name. Owner and group are matched as opaque + // fields — the bare repositories need not belong to a user or group + // literally named "git". + lsEntryRe = regexp.MustCompile(`^\S+\s+\d+\s+\S+\s+\S+\s+\d+\s+(.*)$`) + gitDirRe = regexp.MustCompile(`^(.*)\.git$`) + sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`) + wsRe = regexp.MustCompile(`\s+`) ) +// lsEntry extracts the " " 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 ".git" paths into something else entirely. +func validProject(name string) bool { + return name != "" && !strings.HasPrefix(name, ".") && + !strings.ContainsAny(name, "/\\") +} + +// requireProject checks that a usable project is active, printing an error +// otherwise. Returns true when it is safe to proceed. +func requireProject() bool { + if PRJ == "" { + errorln("no project selected") + return false + } + if !validProject(PRJ) { + errorln("invalid project name: " + PRJ) + return false + } + return true +} + // requireRepo checks that a project with a .git is active, printing an error // otherwise. Returns true when it is safe to proceed. func requireRepo() bool { - if PRJ == "" { - errorln("no project selected") + if !requireProject() { return false } if !isDir(DIR + "/.git") { @@ -93,11 +135,23 @@ func runCommandDepth(line string, depth int) bool { case "unalias": // remove a command alias handleUnalias(word(words, 1)) - case "rescan": // refresh the cached server repository list + case "config": // show the effective configuration and where it came from + if opt["k"] { + fmt.Println(strings.Join(configKeys(), "\n")) + fmt.Println("remote..url|key|type|visibility") + break + } + showConfig() + + case "rescan": // reload the configuration and the cached server repo list + reloadConfig() rescanServer() - fmt.Println("server repository list refreshed") + fmt.Println("configuration and server repository list refreshed") case "dist": // cp changed files to another directory/repository + if !requireRepo() { // `git ls-files` below would otherwise walk BASE + break + } ddir := BASE + "/dist/" + PRJ if w := word(words, 1); w != "" { ddir = w @@ -126,22 +180,22 @@ func runCommandDepth(line string, depth int) bool { fmt.Printf("%d files copied to %s\n", n, ddir) case "list": // list repositories on the git server - path := "." + path, suffix := ".", ".git" if opt["a"] { - path = "./archive" + path, suffix = "./archive", ".git.tar.gz" } pat := word(words, 1) - lines, _ := sshOut("/bin/ls -ltr " + path) + lines, err := sshOut("/bin/ls -ltr " + shq(path)) + if err != nil { + errorln("could not list repositories on the git server") + break + } for _, ln := range lines { if pat != "" && !strings.Contains(strings.ToLower(ln), strings.ToLower(pat)) { continue } - re := lsRepoRe - if opt["a"] { - re = lsArchRe - } - if m := re.FindStringSubmatch(ln); m != nil { - fmt.Println(colorRepoLine(m[1])) + if name := lsEntry(ln, suffix); name != "" { + fmt.Println(colorRepoLine(name)) } } @@ -150,19 +204,22 @@ func runCommandDepth(line string, depth int) bool { if w := word(words, 1); w != "" { prj = w } - found := 0 - lines, _ := sshOut("/bin/ls .") - for _, ln := range lines { - if strings.TrimSpace(ln) == prj+".git" { - found++ - } + if !validProject(prj) { + errorln("usage: show ") + break } - if found == 1 { - logLines, _ := sshOut("cd " + cfg.GitPath + "/" + prj + ".git && git log --reverse --format='%h %ct %s'") - repolog(logLines) - } else { + exists, err := serverEntryExists(".", prj+".git") + if err != nil { + errorln("could not reach the git server") + break + } + if !exists { errorln("repository not found") + break } + logLines, _ := sshOut("cd " + shq(cfg.GitPath+"/"+prj+".git") + + " && git log --reverse --format='%h %ct %s'") + repolog(logLines) case "log": if !requireRepo() { @@ -203,8 +260,7 @@ func runCommandDepth(line string, depth int) bool { gitOK(DIR, "fetch") case "push": // commit everything and push to the server - if PRJ == "" { - errorln("no project selected") + if !requireProject() { break } if fileExists(DIR + "/push.pl") { @@ -221,9 +277,9 @@ func runCommandDepth(line string, depth int) bool { if !gitOK(DIR, "push") { break } - sshOK("touch " + cfg.GitPath + "/" + PRJ + ".git") - if truthy(cfg.Mirror) && cfg.RemoteURL != "" && cfg.RemoteKey != "" { - handlePushRemote("") // auto-mirror to the public server + sshOK("touch " + shq(cfg.GitPath+"/"+PRJ+".git")) + if targets, _ := cfg.mirrorTargets(); truthy(cfg.Mirror) && len(targets) > 0 { + handlePushRemote("") // auto-mirror to every configured server } case "pushremote": // mirror the repo to a public git server via its API @@ -245,8 +301,7 @@ func runCommandDepth(line string, depth int) bool { } case "archive": // snapshot the server-side repository into ./archive - if PRJ == "" { - errorln("no project selected") + if !requireProject() { break } comment := sanitizeComment(strings.Join(fields[1:], " ")) @@ -255,89 +310,104 @@ func runCommandDepth(line string, depth int) bool { if comment != "" { name = PRJ + "_" + z + "_" + comment } - if !sshOK("cp -r " + PRJ + ".git archive/" + name + ".git") { + if !sshOK("cp -r " + shq(PRJ+".git") + " " + shq("archive/"+name+".git")) { break } - if !sshOK("cd archive;tar cvzf " + name + ".git.tar.gz " + name + ".git") { + if !sshOK("cd archive && tar cvzf " + shq(name+".git.tar.gz") + " " + shq(name+".git")) { break } - sshOK("rm -rf archive/" + name + ".git") + sshOK("rm -rf " + shq("archive/"+name+".git")) case "init": // create a new repository from the current directory - if PRJ == "" { - errorln("no project selected") + if !requireProject() { break } if fileExists(DIR + "/push.pl") { runInDir(DIR, "perl", DIR+"/push.pl") } - if REPO == "" || yesno("overwrite existing repository?", false) { - if !sshOK("rm -rf " + cfg.GitPath + "/" + PRJ + ".git") { - break - } - if !sshOK("mkdir " + cfg.GitPath + "/" + PRJ + ".git;cd " + cfg.GitPath + "/" + PRJ + ".git;git --bare init") { - break - } - gi := DIR + "/.gitignore" - if !fileExists(gi) || yesno("overwrite existing .gitignore?", false) { - os.WriteFile(gi, []byte(gitignore), 0644) - } - os.RemoveAll(DIR + "/.git") - if !gitOK(DIR, "init") { - break - } - if !gitOK(DIR, "remote", "add", "origin", URL+"/"+PRJ+".git/") { - break - } - if !gitOK(DIR, "add", ".") { - break - } - if !gitOK(DIR, "commit", "-m", fmt.Sprintf("initial commit [%s,%s]", IP, USER)) { - break - } - gitOK(DIR, "push", "-u", "origin", "master") + // Whether a repository would be destroyed is a property of the *server*, + // not of this checkout's remote.origin.url — an unlinked project + // directory says nothing about what is on the other end. + exists, err := serverEntryExists(".", PRJ+".git") + if err != nil { + errorln("could not reach the git server: " + err.Error()) + break } + if exists && !yesno("overwrite existing repository "+PRJ+" on the server?", false) { + break + } + remote := shq(cfg.GitPath + "/" + PRJ + ".git") + if !sshOK("rm -rf " + remote) { + break + } + if !sshOK("mkdir " + remote + " && cd " + remote + " && git --bare init") { + break + } + gi := DIR + "/.gitignore" + if !fileExists(gi) || yesno("overwrite existing .gitignore?", false) { + if err := os.WriteFile(gi, []byte(gitignore), 0644); err != nil { + errorln("could not write " + gi + ": " + err.Error()) + } + } + os.RemoveAll(DIR + "/.git") + if !gitOK(DIR, "init") { + break + } + if !gitOK(DIR, "remote", "add", "origin", URL+"/"+PRJ+".git/") { + break + } + if !gitOK(DIR, "add", ".") { + break + } + if !gitOK(DIR, "commit", "-m", fmt.Sprintf("initial commit [%s,%s]", resolveIP(), USER)) { + break + } + gitOK(DIR, "push", "-u", "origin", "master") case "login": // open an interactive ssh session to the server - runInDir("", "ssh", "-p", cfg.GitPort, cfg.GitUser+"@"+cfg.GitHost) + runInDir("", "ssh", sshArgs(cfg.GitUser+"@"+cfg.GitHost)...) case "cd": // change the current project - if strings.HasPrefix(word(words, 1), ".") { + arg := word(words, 1) + if strings.HasPrefix(arg, ".") { break // ignore filesystem-relative navigation } - PRJ = "" - if isDir(BASE + "/" + word(words, 1)) { - PRJ = words[1] + PRJ = "" // bare `cd` deselects the project and returns to BASE + if validProject(arg) && isDir(BASE+"/"+arg) { + PRJ = arg } case "checkout": if !requireRepo() { break } - gitOK(DIR, "checkout", word(words, 1)) + if len(fields) < 2 { + errorln("usage: checkout ") + break + } + // forward the raw fields, not the option-stripped words: `checkout -b + // topic` has to reach git with its flag intact. + gitOK(DIR, append([]string{"checkout"}, fields[1:]...)...) case "clone": // clone a repository (or archive with -a) from the server prj := PRJ if w := word(words, 1); w != "" { prj = w } - path := "." + if !validProject(prj) { + errorln("invalid repository name: " + prj) + break + } + path, entry := ".", prj+".git" if opt["a"] { - path = "./archive" + path, entry = "./archive", prj+".git.tar.gz" } - found := 0 - lines, _ := sshOut("/bin/ls " + path) - for _, ln := range lines { - t := strings.TrimSpace(ln) - if opt["a"] { - if t == prj+".git.tar.gz" { - found++ - } - } else if t == prj+".git" { - found++ - } + exists, err := serverEntryExists(path, entry) + if err != nil { + errorln("could not reach the git server: " + err.Error()) + break } - if found != 1 { + if !exists { errorln("repository not found, try 'list [-a]'") break } @@ -353,13 +423,13 @@ func runCommandDepth(line string, depth int) bool { break } } else { - if !sshOK("cd archive;tar xvzf " + prj + ".git.tar.gz") { + if !sshOK("cd archive && tar xvzf " + shq(prj+".git.tar.gz")) { break } if !gitOK(BASE, "clone", URL+"/archive/"+prj+".git") { break } - sshOK("rm -rf archive/" + prj + ".git") + sshOK("rm -rf " + shq("archive/"+prj+".git")) } if isDir(BASE + "/" + prj) { PRJ = prj @@ -370,7 +440,11 @@ func runCommandDepth(line string, depth int) bool { if opt["a"] { path = "./archive" } - lines, _ := sshOut("/bin/ls " + path) + lines, err := sshOut("/bin/ls " + shq(path)) + if err != nil { + errorln("could not list repositories on the git server") + break + } for _, ln := range lines { t := strings.TrimSpace(ln) if m := gitDirRe.FindStringSubmatch(t); m != nil { @@ -385,7 +459,7 @@ func runCommandDepth(line string, depth int) bool { prj = w } d := BASE + "/" + prj - if !isDir(d) { + if !validProject(prj) || !isDir(d) { errorln("not found") break } @@ -400,16 +474,14 @@ func runCommandDepth(line string, depth int) bool { } } } - if xws != "" && xprj != "" { - xprj = "" // prefer the workspace - } - if xws != "" && isDir(d+"/"+xws) { + // prefer the workspace over the project; fall back to the editor unless + // one of them is really openable (a name match on a plain file is not). + switch { + case xws != "" && isDir(d+"/"+xws): runInDir(d, "open", xws) - } - if xprj != "" && isDir(d+"/"+xprj) { + case xprj != "" && isDir(d+"/"+xprj): runInDir(d, "open", xprj) - } - if xws == "" && xprj == "" { + default: editor := cfg.Editor if editor == "" { editor = "coda" @@ -421,9 +493,15 @@ func runCommandDepth(line string, depth int) bool { } case "count": // count source lines in the project + if !requireProject() { + break + } countLines(DIR) case "tag": // manage tags + if !requireRepo() { + break + } sub := word(words, 1) switch { case sub == "add" && word(words, 2) != "": @@ -603,11 +681,11 @@ const gitignore = `.DS_Store ` var helpItems = []struct{ cmd, desc string }{ - {"cd [project]", "change project"}, + {"cd [project]", "change project (no argument: back to the base)"}, {"open [project]", "open project"}, {"init", "make new repository from current directory"}, {"push [comment]", "push changes to git server"}, - {"pushremote [desc]", "mirror repo to a public server (gitea/github/gitlab) via API"}, + {"pushremote [@name] [desc]", "mirror repo to the public server(s) (gitea/github/gitlab)"}, {"pull", "pull changes from git server"}, {"fetch", "fetch changes from git server"}, {"status [-a]", "short git status (-a: overview of all projects)"}, @@ -630,7 +708,8 @@ var helpItems = []struct{ cmd, desc string }{ {"tag delete ", "delete tag"}, {"alias [name [cmd]]", "list, show or define an alias ($1..$N, $* args)"}, {"unalias ", "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"}, {"!", "run in the shell"}, {"quit", "exit mgsh"}, } @@ -640,7 +719,7 @@ func help() { fmt.Printf("%s v%s %s, builtin commands:\n\n", col(cBold+cWhite, "mgsh (git shell)"), col(cYellow, VERSION), INFO) for _, it := range helpItems { - fmt.Printf(" %s%s\n", col(cGreen, padRight(it.cmd, 25)), it.desc) + fmt.Printf(" %s%s\n", col(cGreen, padRight(it.cmd, 27)), it.desc) } fmt.Println() } diff --git a/completion.go b/completion.go index 8efb16c..e7354fb 100644 --- a/completion.go +++ b/completion.go @@ -25,7 +25,7 @@ func completer() *readline.PrefixCompleter { readline.PcItem("show", readline.PcItemDynamic(dynServerRepos)), readline.PcItem("list", readline.PcItem("-a")), readline.PcItem("push"), - readline.PcItem("pushremote"), + readline.PcItem("pushremote", readline.PcItemDynamic(dynRemoteNames)), readline.PcItem("pull"), readline.PcItem("fetch"), readline.PcItem("status", readline.PcItem("-a")), @@ -46,6 +46,7 @@ func completer() *readline.PrefixCompleter { ), readline.PcItem("alias", readline.PcItemDynamic(dynAliasNames)), readline.PcItem("unalias", readline.PcItemDynamic(dynAliasNames)), + readline.PcItem("config", readline.PcItem("-k")), readline.PcItem("rescan"), readline.PcItem("help"), readline.PcItem("quit"), @@ -80,22 +81,30 @@ func fetchServerRepos() { if serverFetched { return } - serverFetched = true - if lines, err := sshOut("/bin/ls ."); err == nil { - for _, ln := range lines { - if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil { - serverRepos = append(serverRepos, m[1]) - } + lines, err := sshOut("/bin/ls .") + if err != nil { + // a transient failure (server down, no network) must not cache an + // empty list for the rest of the session — the next Tab tries again + 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 { for _, ln := range lines { t := strings.TrimSpace(ln) 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) @@ -109,6 +118,17 @@ func rescanServer() { func dynServerRepos(string) []string { fetchServerRepos(); return serverRepos } 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. func dynBranches(string) []string { if !isDir(DIR + "/.git") { diff --git a/config.go b/config.go index fcdca43..6d3b310 100644 --- a/config.go +++ b/config.go @@ -2,22 +2,33 @@ package main import ( "bufio" + "fmt" "os" "path/filepath" + "regexp" + "sort" "strings" ) -// Config holds all externally configurable settings. It is read entirely from -// ~/.mgshrc and then overlaid with MGSH_* environment variables — there are no -// built-in defaults. Missing required settings are a fatal error (see -// missingRequired). +// configMode is the permission mask for ~/.mgshrc. The file holds the +// 'remotekey' API token, so it must not be readable by other local users. +const configMode = 0o600 + +// 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 { - Base string // project base directory + Base string // project base directory (global only) GitHost string // git server host GitPort string // ssh port GitUser string // ssh user 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) GitEmail string // git user.email 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) RemoteVis string // visibility of created repos: "private" (default)|"public" 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..` 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. @@ -64,12 +143,120 @@ func loadConfig() Config { } var c Config 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) 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 // in, migrating any aliases from the legacy ~/.mgsh_aliases file. It writes no // real values — mgsh has no built-in configuration. @@ -89,14 +276,24 @@ func writeConfigTemplate(path string) { b.WriteString("# gitname = Your Name\n") b.WriteString("# gitemail = you@example.com\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 = \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 /.mgshrc.\n") legacy := readLegacyAliases() if blk := aliasBlock(legacy); 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()) return } @@ -124,12 +321,33 @@ func parseConfig(s string) map[string]string { continue } key := strings.ToLower(strings.TrimSpace(line[:i])) - val := strings.Trim(strings.TrimSpace(line[i+1:]), "\"'") - m[key] = val + val := stripInlineComment(strings.TrimSpace(line[i+1:])) + m[key] = strings.Trim(val, "\"'") } 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) { set := func(key string, dst *string) { if v, ok := m[key]; ok && v != "" { @@ -150,7 +368,57 @@ func applyConfig(c *Config, m map[string]string) { set("remotekey", &c.RemoteKey) set("remotetype", &c.RemoteType) set("remotevisibility", &c.RemoteVis) + set("remotes", &c.RemoteNames) set("mirror", &c.Mirror) + applyRemoteTargets(c, m) +} + +// remoteFieldRe matches a named mirror target setting: remote... +var remoteFieldRe = regexp.MustCompile(`^remote\.([a-z0-9_.-]+)\.(url|key|type|visibility)$`) + +// applyRemoteTargets merges `remote..` 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) { @@ -173,5 +441,6 @@ func applyEnv(c *Config) { env("MGSH_REMOTEKEY", &c.RemoteKey) env("MGSH_REMOTETYPE", &c.RemoteType) env("MGSH_REMOTEVISIBILITY", &c.RemoteVis) + env("MGSH_REMOTES", &c.RemoteNames) env("MGSH_MIRROR", &c.Mirror) } diff --git a/git.go b/git.go index 8ae79ef..7449ab7 100644 --- a/git.go +++ b/git.go @@ -3,6 +3,7 @@ package main import ( "os" "os/exec" + "path/filepath" "strings" ) @@ -18,8 +19,66 @@ func runInDir(dir, name string, args ...string) error { 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 { - 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. @@ -36,13 +95,22 @@ func gitCapture(dir string, args ...string) (string, error) { if dir != "" { c.Dir = dir } + c.Env = gitEnv() out, err := c.Output() 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. 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. @@ -56,7 +124,7 @@ func sshOK(remote string) bool { // sshOut runs a remote command and returns its stdout split into lines. 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 out, err := c.Output() lines := strings.Split(string(out), "\n") @@ -66,6 +134,22 @@ func sshOut(remote string) ([]string, error) { 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. func forwardShell(line string) { runInDir(DIR, "/bin/sh", "-c", line) diff --git a/input.go b/input.go index 8ab8398..59a15cd 100644 --- a/input.go +++ b/input.go @@ -5,6 +5,8 @@ import ( "os" "os/exec" "strings" + + "github.com/chzyer/readline" ) // 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 == "" { return def } - return ans == "y" || ans == "yes" + return ans == "y" } // 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 // 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 { fmt.Print(prompt) - stty("-icanon", "-echo") + tty := readline.IsTerminal(int(os.Stdin.Fd())) + if tty { + stty("-icanon", "-echo") + } var buf [1]byte n, err := os.Stdin.Read(buf[:]) - stty("icanon", "echo") + if tty { + drainTTY() + stty("icanon", "echo") + } key := "" if err == nil && n > 0 { key = strings.Trim(string(buf[:n]), "\r\n\t") @@ -37,6 +49,20 @@ func getkey(prompt string) string { 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) { c := exec.Command("stty", args...) c.Stdin = os.Stdin diff --git a/main.go b/main.go index 028b482..e99ea55 100644 --- a/main.go +++ b/main.go @@ -28,15 +28,16 @@ const INFO = "mwx'2026" // Global runtime state. var ( - cfg Config // resolved configuration - BASE string // project base directory (cfg.Base) + baseCfg Config // global configuration: ~/.mgshrc + MGSH_* + 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 PRJ string // current project (may be empty) DIR string // current working directory (BASE or BASE/PRJ) - IP string // local IP, embedded into the initial commit message HOST string // short hostname 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 BRANCH string // current git branch of the active project (if any) DIRTY bool // whether the active project has uncommitted changes @@ -50,7 +51,7 @@ func main() { fmt.Println(banner()) // 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] } @@ -61,11 +62,7 @@ func main() { return } if clCmd != 0 { - pwd, _ := os.Getwd() - PRJ = "" - if strings.HasPrefix(pwd, BASE+"/") { - PRJ = pwd[len(BASE)+1:] - } + PRJ = projectFromCwd() if clCmd == 2 { cmdline = strings.TrimSpace(cmdline + " " + PRJ) } @@ -106,6 +103,9 @@ func runInteractive() { continue } else if err == io.EOF { // Ctrl-D break + } else if err != nil { // detached/broken terminal: don't spin + fmt.Fprintln(os.Stderr, err) + break } if !runCommand(strings.TrimSpace(line)) { 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 // "CLCMD" class (0 = interactive, 1 = plain, 2 = append project name), the raw // command line, and whether `help` was requested. @@ -128,6 +147,7 @@ func parseArgs() (int, string, bool) { "clone": 2, "init": 2, "log": 2, "push": 1, "pushremote": 1, "list": 1, "tag": 1, "archive": 1, "show": 1, "open": 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 { return c, strings.Join(os.Args[1:], " "), false @@ -136,45 +156,108 @@ func parseArgs() (int, string, bool) { } func setup() { - cfg = loadConfig() - if miss := cfg.missingRequired(); len(miss) > 0 { + // the *global* configuration has to stand on its own: mgsh must be usable + // 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, "edit %s or set the corresponding MGSH_* environment variables, then run mgsh again.\n", configFile()) os.Exit(1) } - BASE = cfg.Base + BASE = baseCfg.Base if !isDir(BASE) { fmt.Fprintf(os.Stderr, "%s not found\n", BASE) os.Exit(1) } - URL = fmt.Sprintf("ssh://%s@%s:%s%s", cfg.GitUser, cfg.GitHost, cfg.GitPort, cfg.GitPath) - IP = resolveIP() + applyProjectConfig() // no project yet: cfg = baseCfg, URL from it HOST = shortHostname() if u, err := user.Current(); err == nil { USER = u.Username } - // git identity / settings, from config (defaults preserve prior behavior) - 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) - } - + applyGlobalGitConfig() loadAliases() } -// updateDirState recomputes DIR, the build.pl OFF marker, the current branch / -// dirty flag and the parsed repository name for the active project. Run once -// per loop iteration. +// applyGlobalGitConfig writes the configured git identity to ~/.gitconfig, but +// only the values that actually differ — every mgsh invocation runs this, and a +// 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() { BRANCH = "" DIRTY = false + applyProjectConfig() if PRJ != "" && isDir(BASE+"/"+PRJ) { DIR = BASE + "/" + PRJ @@ -197,13 +280,19 @@ func updateDirState() { DIR = BASE BPLSTATE = "" } - - // derive REPO from the origin URL: basename without the ".git" suffix. - REPO = "" - if out, err := gitCapture(DIR, "config", "remote.origin.url"); err == nil { - remurl := strings.TrimRight(strings.TrimSpace(out), "/") - if remurl != "" { - REPO = strings.TrimSuffix(filepath.Base(remurl), ".git") - } - } +} + +// originURL returns the active project's own origin URL, or "" when it is not a +// repository or has no origin. `--local` matters: a plain `git config` walks up +// into an enclosing repository and would report a foreign origin for a project +// that has no repository of its own. +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) } diff --git a/mgsh_test.go b/mgsh_test.go index 494c955..462155c 100644 --- a/mgsh_test.go +++ b/mgsh_test.go @@ -2,7 +2,9 @@ package main import ( "os" + "os/exec" "path/filepath" + "reflect" "strconv" "strings" "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) { c := Config{GitName: "Original Name"} 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) { full := Config{Base: "/b", GitHost: "h", GitPort: "22", GitUser: "u", GitPath: "/p"} if m := full.missingRequired(); len(m) != 0 { diff --git a/remote.go b/remote.go index d81958a..bec73f1 100644 --- a/remote.go +++ b/remote.go @@ -4,14 +4,28 @@ package main // git hosting server (Gitea, GitHub or GitLab), creating the repository via the // 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 // remotekey = personal access token // 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 = +// remote.hub.url = https://github.com +// remote.hub.key = +// 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 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 ( "bytes" @@ -208,40 +222,105 @@ func (r *remoteAPI) repoWebURL(owner, repo string) string { return base + "/" + owner + "/" + repo + ".git" } -// handlePushRemote implements the `pushremote [description]` command. The -// description, if given, is set on the repository when it is created. -func handlePushRemote(description string) { +// parsePushRemoteArgs splits `pushremote [@name ...] [description]` into the +// selected target names and the description. The '@' sigil keeps the two apart: +// 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() { return } - if cfg.RemoteURL == "" || cfg.RemoteKey == "" { - errorln("pushremote needs 'remoteurl' and 'remotekey' in ~/.mgshrc") - return + names, description := parsePushRemoteArgs(args) + + targets, incomplete := cfg.mirrorTargets() + for _, n := range incomplete { + errorln("remote " + n + ": url or key missing — skipped") } - repo := PRJ - if repo == "" { - repo = REPO - } - if repo == "" { - errorln("cannot determine repository name") + targets = pickRemotes(targets, names) + if len(targets) == 0 { + if len(names) == 0 { // an unknown @name already reported itself + errorln("pushremote needs 'remoteurl'/'remotekey' or a 'remote..*' block in " + configFile()) + } 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() if err != nil { - errorln(err.Error()) - return + errorln(t.Name + ": " + err.Error()) + 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) if err != nil { - errorln(err.Error()) - return + errorln(t.Name + ": " + err.Error()) + return false } if exists { 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)) if err := api.createRepo(repo, description, private); err != nil { - errorln(err.Error()) - return + errorln(t.Name + ": " + err.Error()) + 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) - if _, err := gitCapture(DIR, "remote", "get-url", "public"); err == nil { - gitOK(DIR, "remote", "set-url", "public", web) + if _, err := gitCapture(DIR, "remote", "get-url", t.Name); err == nil { + gitOK(DIR, "remote", "set-url", t.Name, web) } 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 - // never persisted in the repository's git config + // authenticate the push with a one-shot Basic auth header, so the token is + // neither persisted in the repository's git config nor visible in `ps` header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(owner+":"+api.key)) - if !gitPushHeader(DIR, "public", header, "--all") { - return + if !gitPushHeader(DIR, t.Name, header, "--all") { + return false } - gitPushHeader(DIR, "public", header, "--tags") + gitPushHeader(DIR, t.Name, header, "--tags") fmt.Println(col(cGreen, "pushed to ") + col(cCyan, web)) + return true } // gitPushHeader runs `git push ` with an extra HTTP auth // 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 { - full := append([]string{"-c", "http.extraHeader=" + header, "push", remote}, args...) - c := exec.Command("git", full...) + c := exec.Command("git", append([]string{"push", remote}, args...)...) c.Dir = dir c.Stdin = os.Stdin c.Stdout = os.Stdout 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 { errorln("git push " + remote + " " + strings.Join(args, " ") + " failed") return false diff --git a/show_config.go b/show_config.go new file mode 100644 index 0000000..88fc88c --- /dev/null +++ b/show_config.go @@ -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..*` 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 +}