Files
mgsh/completion.go
T
mikeandClaude Opus 5 ae8c7a3ec0 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>
2026-07-26 11:37:49 +02:00

187 lines
5.1 KiB
Go

package main
import (
"os"
"path/filepath"
"strings"
"github.com/chzyer/readline"
)
// completer wires up Tab completion. Command names complete at the start of the
// line; cd/open/view complete local project names; clone/show complete
// repository names cached from the git server; checkout/tag complete branch and
// tag names; dist completes filesystem paths.
func completer() *readline.PrefixCompleter {
return readline.NewPrefixCompleter(
readline.PcItem("cd", readline.PcItemDynamic(dynLocalProjects)),
readline.PcItem("open", readline.PcItemDynamic(dynLocalProjects)),
readline.PcItem("view", readline.PcItemDynamic(dynLocalProjects)),
readline.PcItem("clone",
readline.PcItem("-a", readline.PcItemDynamic(dynServerArchives)),
readline.PcItemDynamic(dynServerRepos),
),
readline.PcItem("cloneall"),
readline.PcItem("show", readline.PcItemDynamic(dynServerRepos)),
readline.PcItem("list", readline.PcItem("-a")),
readline.PcItem("push"),
readline.PcItem("pushremote", readline.PcItemDynamic(dynRemoteNames)),
readline.PcItem("pull"),
readline.PcItem("fetch"),
readline.PcItem("status", readline.PcItem("-a")),
readline.PcItem("overview"),
readline.PcItem("diff"),
readline.PcItem("init"),
readline.PcItem("edit"),
readline.PcItem("checkout", readline.PcItemDynamic(dynCheckout)),
readline.PcItem("log"),
readline.PcItem("archive"),
readline.PcItem("dist", readline.PcItemDynamic(dynPaths)),
readline.PcItem("login"),
readline.PcItem("count"),
readline.PcItem("tag",
readline.PcItem("add"),
readline.PcItem("checkout", readline.PcItemDynamic(dynTags)),
readline.PcItem("delete", readline.PcItemDynamic(dynTags)),
),
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"),
readline.PcItem("exit"),
)
}
// dynLocalProjects lists project directories under BASE (already name-sorted).
func dynLocalProjects(string) []string {
entries, err := os.ReadDir(BASE)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
out = append(out, e.Name())
}
}
return out
}
var (
serverRepos []string
serverArchives []string
serverFetched bool
)
// fetchServerRepos queries the server once and caches the repository/archive
// names for Tab completion.
func fetchServerRepos() {
if serverFetched {
return
}
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") {
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)
// re-fetches it.
func rescanServer() {
serverFetched = false
serverRepos = nil
serverArchives = nil
}
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") {
return nil
}
out, _ := gitCapture(DIR, "for-each-ref", "--format=%(refname:short)", "refs/heads")
return splitLines(out)
}
func dynTags(string) []string {
if !isDir(DIR + "/.git") {
return nil
}
out, _ := gitCapture(DIR, "tag", "-l")
return splitLines(out)
}
// dynCheckout offers both branches and tags for `checkout`.
func dynCheckout(line string) []string {
return append(dynBranches(line), dynTags(line)...)
}
// dynPaths completes filesystem paths for the last token on the line.
func dynPaths(line string) []string {
partial := ""
if !strings.HasSuffix(line, " ") {
if f := strings.Fields(line); len(f) > 0 {
partial = f[len(f)-1]
}
}
dir := "."
if partial != "" {
if strings.HasSuffix(partial, "/") {
dir = partial
} else {
dir = filepath.Dir(partial)
}
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") {
continue
}
p := filepath.Join(dir, e.Name())
if e.IsDir() {
p += "/"
}
out = append(out, p)
}
return out
}