overview showed dirty and ahead/behind per project, which git can do on its own. mgsh is the only thing that sees both the local base directory and the ssh server, and joining those answers the questions git cannot: which projects were never pushed to the server (candidates for `init`), and which exist there but not on this machine (candidates for `clone`). Both lists are printed after the summary. An unreachable server is reported as such, rather than as "everything is missing". Each row also names the machine that made the last commit and how long ago. That costs nothing: `push` has always stamped "[user@host]" into the commit message, and nothing ever read it back. On a setup spanning several machines it is usually the piece one actually wanted. Rows also show which mirror targets the repository has a remote for, which is local git config and therefore free. The walk is now concurrent and cheaper per project: `git status --porcelain=v2 --branch` yields branch, upstream, ahead/behind and dirty in one subprocess where three were used before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
5.0 KiB
Go
182 lines
5.0 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("release", 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
|
|
}
|
|
repos, err := serverRepoNames()
|
|
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
|
|
}
|
|
// 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
|
|
}
|