Files
mgsh/alias.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

292 lines
8.1 KiB
Go

package main
// alias.go — user-defined command aliases, persisted in ~/.mgshrc.
//
// An alias maps a name to an expansion template, which is itself a mgsh command
// line. The template may reference the arguments passed to the alias:
//
// $1 … $N the Nth argument ("" when missing)
// $* $@ all arguments, space-joined
//
// When the template contains no placeholder, the arguments are appended (the
// classic shell-alias behaviour). Since mgsh does not forward unknown commands
// to a shell, a shell command inside an alias needs the '!' prefix, e.g.
//
// alias ec '!echo $1' ec hello -> runs `echo hello`
// alias co 'checkout $1' co v2 -> runs the `checkout` builtin
// alias p 'push $*' p fixed bug -> runs `push fixed bug`
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
// aliases maps an alias name to its expansion template. Loaded at startup and
// re-persisted on every change.
var aliases = map[string]string{}
// maxAliasDepth bounds recursive alias expansion (guards against cycles).
const maxAliasDepth = 16
var (
aliasNameRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
aliasVarRe = regexp.MustCompile(`\$(\d+|\*|@)`)
)
// builtinCmds is the set of reserved command words that cannot be shadowed by
// an alias (mirrors the switch in runCommand).
var builtinCmds = map[string]bool{
"": true, "quit": true, "exit": true, "help": true, "rescan": true,
"dist": true, "list": true, "show": true, "log": true, "status": true,
"diff": true, "pull": true, "fetch": true, "push": true, "edit": true,
"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, "config": true,
}
func isBuiltin(name string) bool { return builtinCmds[name] }
// aliasHeader labels the managed alias block inside ~/.mgshrc.
const aliasHeader = "# aliases — managed by the `alias` command; format: alias <name> '<expansion>'"
// legacyAliasFile is the pre-4.x standalone alias file, migrated into ~/.mgshrc
// on first run.
func legacyAliasFile() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".mgsh_aliases")
}
// loadAliases reads alias definitions from ~/.mgshrc. Builtins are skipped so a
// hand-edited file can never shadow a real command.
func loadAliases() {
data, err := os.ReadFile(configFile())
if err != nil {
return
}
for _, ln := range strings.Split(string(data), "\n") {
if name, body, ok := parseAliasLine(ln); ok && !isBuiltin(name) {
aliases[name] = body
}
}
}
// parseAliasLine parses one "alias <name> '<body>'" line. ok is false for any
// line that is not an alias definition.
func parseAliasLine(ln string) (name, body string, ok bool) {
t := strings.TrimSpace(ln)
if f := strings.Fields(t); len(f) < 2 || f[0] != "alias" {
return "", "", false
}
rest := strings.TrimSpace(strings.TrimPrefix(t, "alias"))
i := strings.IndexAny(rest, " \t")
if i < 0 {
return "", "", false
}
name = rest[:i]
body = unquote(strings.TrimSpace(rest[i+1:]))
if name == "" || body == "" {
return "", "", false
}
return name, body, true
}
// readLegacyAliases parses the old ~/.mgsh_aliases file ("name<whitespace>body"
// lines), returning nil when it does not exist.
func readLegacyAliases() map[string]string {
data, err := os.ReadFile(legacyAliasFile())
if err != nil {
return nil
}
m := map[string]string{}
for _, ln := range strings.Split(string(data), "\n") {
t := strings.TrimSpace(ln)
if t == "" || strings.HasPrefix(t, "#") {
continue
}
i := strings.IndexAny(t, " \t")
if i < 0 {
continue
}
name := t[:i]
body := strings.TrimSpace(t[i+1:])
if name != "" && body != "" && !isBuiltin(name) {
m[name] = body
}
}
return m
}
// aliasBlock renders the managed alias section for a set of aliases (sorted by
// name), or "" when there are none.
func aliasBlock(m map[string]string) string {
if len(m) == 0 {
return ""
}
names := make([]string, 0, len(m))
for n := range m {
names = append(names, n)
}
sort.Strings(names)
var b strings.Builder
b.WriteString(aliasHeader + "\n")
for _, n := range names {
fmt.Fprintf(&b, "alias %s '%s'\n", n, m[n])
}
return b.String()
}
// saveAliases rewrites ~/.mgshrc, preserving the configuration lines and
// replacing the managed alias block with the current alias set.
func saveAliases() {
path := configFile()
var keep []string
if data, err := os.ReadFile(path); err == nil {
for _, ln := range strings.Split(string(data), "\n") {
if strings.TrimSpace(ln) == aliasHeader {
continue
}
if _, _, ok := parseAliasLine(ln); ok {
continue
}
keep = append(keep, ln)
}
}
// drop any trailing blank lines so the block isn't pushed down over time
for len(keep) > 0 && strings.TrimSpace(keep[len(keep)-1]) == "" {
keep = keep[:len(keep)-1]
}
var b strings.Builder
for _, ln := range keep {
b.WriteString(ln + "\n")
}
if blk := aliasBlock(aliases); blk != "" {
b.WriteString("\n" + blk)
}
// 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())
}
}
// aliasNames returns the alias names, sorted.
func aliasNames() []string {
names := make([]string, 0, len(aliases))
for n := range aliases {
names = append(names, n)
}
sort.Strings(names)
return names
}
// handleAlias implements the `alias` command:
//
// alias list all aliases
// alias <name> show a single alias
// alias <name> <cmd> define an alias (surrounding quotes on <cmd> optional)
func handleAlias(line string) {
rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "alias"))
if rest == "" {
listAliases()
return
}
i := strings.IndexAny(rest, " \t")
if i < 0 { // just a name: show it
if body, ok := aliases[rest]; ok {
fmt.Println(formatAlias(rest, body))
} else {
errorln("alias not defined: " + rest)
}
return
}
name := rest[:i]
body := unquote(strings.TrimSpace(rest[i+1:]))
switch {
case !aliasNameRe.MatchString(name):
errorln("invalid alias name: " + name)
case isBuiltin(name):
errorln("cannot alias builtin command: " + name)
case body == "":
errorln("empty alias definition")
default:
aliases[name] = body
saveAliases()
fmt.Println(formatAlias(name, body))
}
}
// handleUnalias removes an alias by name.
func handleUnalias(name string) {
if name == "" {
errorln("usage: unalias <name>")
return
}
if _, ok := aliases[name]; !ok {
errorln("alias not defined: " + name)
return
}
delete(aliases, name)
saveAliases()
fmt.Println("removed alias " + col(cGreen, name))
}
func listAliases() {
if len(aliases) == 0 {
fmt.Println(col(cGray, "no aliases defined"))
return
}
for _, n := range aliasNames() {
fmt.Println(formatAlias(n, aliases[n]))
}
}
// formatAlias renders `name = 'body'` with color.
func formatAlias(name, body string) string {
return col(cGreen, name) + col(cGray, " = ") + col(cYellow, "'"+body+"'")
}
// expandAlias substitutes positional parameters in an alias body. With no
// placeholder present, the arguments are appended instead.
func expandAlias(body string, args []string) string {
all := strings.Join(args, " ")
used := false
out := aliasVarRe.ReplaceAllStringFunc(body, func(m string) string {
used = true
tok := m[1:]
if tok == "*" || tok == "@" {
return all
}
n, _ := strconv.Atoi(tok)
if n >= 1 && n <= len(args) {
return args[n-1]
}
return ""
})
if !used && len(args) > 0 {
out = strings.TrimRight(out, " ") + " " + all
}
return strings.TrimSpace(out)
}
// unquote strips one pair of matching surrounding single or double quotes.
func unquote(s string) string {
if len(s) >= 2 {
if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') {
return s[1 : len(s)-1]
}
}
return s
}
// dynAliasNames feeds Tab completion for `alias`/`unalias`.
func dynAliasNames(string) []string { return aliasNames() }