Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d0d28560e | ||
|
|
2acca170e6 |
@@ -53,8 +53,9 @@ project, its git branch and a `*` dirty marker:
|
||||
|
||||
Features: command history (`~/.mgsh_history`), Tab completion (commands, local
|
||||
projects for `cd`/`open`, server repos for `clone`/`show`, branches/tags for
|
||||
`checkout`/`tag`, mirror targets for `pushremote`, filesystem paths for `dist`),
|
||||
and colored `list`/`log`/error output.
|
||||
`checkout`/`tag`, mirror targets for `pushremote`/`release`, filesystem paths for
|
||||
`dist`, and shell-style completion after `!` and for aliases that expand to
|
||||
one), and colored `list`/`log`/error output.
|
||||
|
||||
The server repository list is fetched once per session on the first Tab that
|
||||
needs it; `rescan` refreshes it (and reloads the configuration).
|
||||
@@ -70,6 +71,34 @@ prefix it with `!`:
|
||||
< src/myproject > !ls -la
|
||||
```
|
||||
|
||||
It runs in the active project's directory. Tab completion works there the way it
|
||||
does in a shell: the word after the `!` completes against the executables on
|
||||
`PATH`, everything after it against the filesystem — relative to the project,
|
||||
with `~/` and absolute paths understood, and directories completing with their
|
||||
trailing slash so the next Tab walks into them. Dot entries stay out of the way
|
||||
until the prefix asks for one.
|
||||
|
||||
```
|
||||
< src/myproject > !vi ma<Tab> -> !vi main
|
||||
< src/myproject > !vi <Tab> -> Makefile main.go main_test.go src/
|
||||
< src/myproject > !gre<Tab> -> grep gresource
|
||||
```
|
||||
|
||||
An alias that expands to a shell escape completes the same way, because its
|
||||
arguments end up as shell arguments:
|
||||
|
||||
```
|
||||
alias ll '!ls -la'
|
||||
< src/myproject > ll ma<Tab> -> ll main
|
||||
```
|
||||
|
||||
Only the alias's arguments complete, never its first word — the command is
|
||||
fixed by the alias body. An alias to a builtin (`alias co 'checkout $1'`) is not
|
||||
a shell line and is left alone.
|
||||
|
||||
Word splitting for completion is by whitespace only; quotes and backslash
|
||||
escapes are left to the shell that runs the line.
|
||||
|
||||
### Commands
|
||||
|
||||
Run `help` for the full list. Highlights:
|
||||
|
||||
+41
-4
@@ -4,15 +4,52 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"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
|
||||
// completer wires up Tab completion. A line headed for a shell — a '!' escape,
|
||||
// or an alias that expands to one — is completed the way a shell would:
|
||||
// executables for the command, paths for its arguments. Everything else goes to
|
||||
// the builtin command tree.
|
||||
func completer() readline.AutoCompleter {
|
||||
return &mgshCompleter{builtin: builtinCompleter()}
|
||||
}
|
||||
|
||||
// mgshCompleter dispatches between the two completion worlds.
|
||||
type mgshCompleter struct{ builtin *readline.PrefixCompleter }
|
||||
|
||||
func (c *mgshCompleter) Do(line []rune, pos int) ([][]rune, int) {
|
||||
if pos > len(line) {
|
||||
pos = len(line)
|
||||
}
|
||||
if cands, prefix, ok := completeShellLine(string(line[:pos])); ok {
|
||||
return runeSuffixes(cands, prefix)
|
||||
}
|
||||
return c.builtin.Do(line, pos)
|
||||
}
|
||||
|
||||
// runeSuffixes converts full candidate words into what readline wants: the part
|
||||
// still missing after the prefix already typed, plus that prefix's length.
|
||||
func runeSuffixes(cands []string, prefix string) ([][]rune, int) {
|
||||
n := utf8.RuneCountInString(prefix)
|
||||
out := make([][]rune, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
r := []rune(c)
|
||||
if len(r) >= n {
|
||||
out = append(out, r[n:])
|
||||
}
|
||||
}
|
||||
return out, n
|
||||
}
|
||||
|
||||
// builtinCompleter is the command tree. 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 {
|
||||
// tag names; pushremote/release complete mirror targets; dist completes
|
||||
// filesystem paths.
|
||||
func builtinCompleter() *readline.PrefixCompleter {
|
||||
return readline.NewPrefixCompleter(
|
||||
readline.PcItem("cd", readline.PcItemDynamic(dynLocalProjects)),
|
||||
readline.PcItem("open", readline.PcItemDynamic(dynLocalProjects)),
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
// shellcomplete.go — Tab completion for the '!' shell escape.
|
||||
//
|
||||
// `!vi <Tab>` should behave like it does in a shell: the first word completes
|
||||
// against the executables on PATH, everything after it against the filesystem.
|
||||
// Paths resolve relative to the active project directory, because that is where
|
||||
// forwardShell runs the command.
|
||||
//
|
||||
// Word splitting here is whitespace only. Quoting and backslash escapes are the
|
||||
// shell's business at execution time; getting them right for completion too
|
||||
// would buy little for a one-off escape hatch.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// shellCommandList supplies the executable names for the command position. A
|
||||
// variable so tests can hand over a fixed set instead of whatever happens to be
|
||||
// installed on the machine running them.
|
||||
var shellCommandList = pathExecutables
|
||||
|
||||
// completeShellLine returns the candidates and the prefix they replace for a
|
||||
// line that is headed for a shell. ok is false for any other line, which is
|
||||
// then left to the builtin command tree.
|
||||
func completeShellLine(typed string) (cands []string, prefix string, ok bool) {
|
||||
body, allowCommand, ok := shellLine(typed)
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
}
|
||||
cands, prefix = shellCandidates(body, allowCommand)
|
||||
return cands, prefix, true
|
||||
}
|
||||
|
||||
// shellLine works out which part of a typed line will reach a shell, and
|
||||
// whether its command word is still open for completion. Two things get there:
|
||||
// a '!' escape, and an alias that expands to one — `alias ll '!ls -la'` makes
|
||||
// everything after `ll` a shell argument just as surely.
|
||||
//
|
||||
// Only the alias itself is inspected, not what its expansion might expand to
|
||||
// again: an alias chain can rewrite its arguments, and guessing at that would
|
||||
// offer candidates for a command line that is not the one being built.
|
||||
func shellLine(typed string) (body string, allowCommand, ok bool) {
|
||||
trimmed := strings.TrimLeft(typed, " \t")
|
||||
if rest, found := strings.CutPrefix(trimmed, "!"); found {
|
||||
return rest, true, true
|
||||
}
|
||||
|
||||
// the alias name has to be complete — while it is still being typed there
|
||||
// is no way to know what it will turn out to be
|
||||
sep := strings.IndexAny(trimmed, " \t")
|
||||
if sep < 0 {
|
||||
return "", false, false
|
||||
}
|
||||
name := trimmed[:sep]
|
||||
if isBuiltin(name) { // a builtin can never be shadowed by an alias
|
||||
return "", false, false
|
||||
}
|
||||
expansion, defined := aliases[name]
|
||||
if !defined || !strings.HasPrefix(strings.TrimSpace(expansion), "!") {
|
||||
return "", false, false
|
||||
}
|
||||
// the command comes from the alias body, so only arguments are left to complete
|
||||
return trimmed[sep:], false, true
|
||||
}
|
||||
|
||||
// shellCandidates completes the last word of a shell command line. allowCommand
|
||||
// says whether its first word may still be completed against PATH.
|
||||
func shellCandidates(body string, allowCommand bool) (cands []string, prefix string) {
|
||||
word := body[strings.LastIndexAny(body, " \t")+1:]
|
||||
inCommand := allowCommand && strings.TrimLeft(body[:len(body)-len(word)], " \t") == ""
|
||||
|
||||
// a command word without a separator names something on PATH; with one it
|
||||
// is a path like ./script, exactly as a shell reads it
|
||||
if inCommand && !strings.ContainsRune(word, '/') {
|
||||
if word == "" {
|
||||
return nil, "" // every executable on the machine helps nobody
|
||||
}
|
||||
return matchPrefix(shellCommandList(), word), word
|
||||
}
|
||||
|
||||
dir, base := splitPathToken(word)
|
||||
return matchPrefix(pathEntries(dir), base), base
|
||||
}
|
||||
|
||||
// splitPathToken splits a path token into the directory part, kept exactly as
|
||||
// typed, and the basename being completed. Completing only the basename is what
|
||||
// keeps the candidate list readable: "src/ma<Tab>" offers "main.go", not the
|
||||
// whole path again.
|
||||
func splitPathToken(word string) (dir, base string) {
|
||||
if i := strings.LastIndexByte(word, '/'); i >= 0 {
|
||||
return word[:i+1], word[i+1:]
|
||||
}
|
||||
return "", word
|
||||
}
|
||||
|
||||
// pathEntries lists what a directory token points at. Directories come back
|
||||
// with a trailing slash, so completing one leads straight into it.
|
||||
func pathEntries(dir string) []string {
|
||||
root := DIR
|
||||
switch {
|
||||
case strings.HasPrefix(dir, "~/"):
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
root, dir = home, dir[2:]
|
||||
case strings.HasPrefix(dir, "/"):
|
||||
root = ""
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(filepath.Join(root, dir))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
name += "/"
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchPrefix keeps the candidates starting with prefix, sorted and without
|
||||
// duplicates. A hidden entry only shows up once the prefix asks for it, as in a
|
||||
// shell.
|
||||
func matchPrefix(cands []string, prefix string) []string {
|
||||
wantHidden := strings.HasPrefix(prefix, ".")
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, c := range cands {
|
||||
if !strings.HasPrefix(c, prefix) || seen[c] {
|
||||
continue
|
||||
}
|
||||
if !wantHidden && strings.HasPrefix(c, ".") {
|
||||
continue
|
||||
}
|
||||
seen[c] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// pathExecutables lists the executable names on PATH. The scan happens once per
|
||||
// session: PATH cannot change from inside mgsh, and a few thousand directory
|
||||
// entries are not worth walking on every Tab.
|
||||
var pathExecutables = sync.OnceValue(func() []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if seen[name] {
|
||||
continue // the first one on PATH is the one that would run
|
||||
}
|
||||
fi, err := e.Info()
|
||||
if err != nil || fi.IsDir() || fi.Mode().Perm()&0o111 == 0 {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// complete runs the real dispatch a Tab press goes through.
|
||||
func complete(typed string) ([]string, string) {
|
||||
cands, prefix, _ := completeShellLine(typed)
|
||||
return cands, prefix
|
||||
}
|
||||
|
||||
// fakeCommands installs a fixed set of PATH executables for the test.
|
||||
func fakeCommands(t *testing.T, names ...string) {
|
||||
t.Helper()
|
||||
old := shellCommandList
|
||||
shellCommandList = func() []string { return names }
|
||||
t.Cleanup(func() { shellCommandList = old })
|
||||
}
|
||||
|
||||
// shellTree lays out a directory to complete against and points DIR at it.
|
||||
func shellTree(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, d := range []string{"src", "src/deep", ".hidden"} {
|
||||
if err := os.MkdirAll(filepath.Join(dir, d), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"main.go", "main_test.go", "Makefile", ".env", "src/util.go"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
old := DIR
|
||||
DIR = dir
|
||||
t.Cleanup(func() { DIR = old })
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestShellCandidatesCommandWord(t *testing.T) {
|
||||
fakeCommands(t, "vi", "vim", "view", "grep", "git")
|
||||
shellTree(t)
|
||||
|
||||
cands, prefix := complete("!vi")
|
||||
if prefix != "vi" {
|
||||
t.Errorf("prefix = %q, want vi", prefix)
|
||||
}
|
||||
if strings.Join(cands, ",") != "vi,view,vim" {
|
||||
t.Errorf("candidates = %v, want vi,view,vim sorted", cands)
|
||||
}
|
||||
|
||||
// a bare '!' must not dump every executable on the machine
|
||||
if cands, _ := complete("!"); len(cands) != 0 {
|
||||
t.Errorf("bare '!' offered %d candidates", len(cands))
|
||||
}
|
||||
// leading blanks are allowed, as runCommand allows them
|
||||
if cands, _ := complete(" !gi"); strings.Join(cands, ",") != "git" {
|
||||
t.Errorf("indented escape = %v, want git", cands)
|
||||
}
|
||||
// a command word with a separator is a path, not a PATH lookup
|
||||
if cands, prefix := complete("!./ma"); prefix != "ma" ||
|
||||
strings.Join(cands, ",") != "main.go,main_test.go" {
|
||||
t.Errorf("./ma = %v (prefix %q), want the local files", cands, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellCandidatesArguments(t *testing.T) {
|
||||
fakeCommands(t, "vi")
|
||||
shellTree(t)
|
||||
|
||||
// paths resolve against the project directory, where `!` commands run
|
||||
cands, prefix := complete("!vi ma")
|
||||
if prefix != "ma" || strings.Join(cands, ",") != "main.go,main_test.go" {
|
||||
t.Errorf("candidates = %v (prefix %q)", cands, prefix)
|
||||
}
|
||||
|
||||
// an empty argument lists the directory — dot entries stay out of the way
|
||||
cands, prefix = complete("!vi ")
|
||||
if prefix != "" {
|
||||
t.Errorf("prefix = %q, want empty", prefix)
|
||||
}
|
||||
if strings.Join(cands, ",") != "Makefile,main.go,main_test.go,src/" {
|
||||
t.Errorf("directory listing = %v", cands)
|
||||
}
|
||||
|
||||
// ... until the prefix asks for them
|
||||
if cands, _ := complete("!vi ."); strings.Join(cands, ",") != ".env,.hidden/" {
|
||||
t.Errorf("dot prefix = %v, want the hidden entries", cands)
|
||||
}
|
||||
|
||||
// a directory completes with its slash, so the next Tab walks into it
|
||||
cands, prefix = complete("!vi sr")
|
||||
if prefix != "sr" || strings.Join(cands, ",") != "src/" {
|
||||
t.Errorf("directory candidate = %v (prefix %q)", cands, prefix)
|
||||
}
|
||||
|
||||
// inside a directory only the basename is completed, which is what keeps
|
||||
// the candidate list readable
|
||||
cands, prefix = complete("!vi src/ut")
|
||||
if prefix != "ut" || strings.Join(cands, ",") != "util.go" {
|
||||
t.Errorf("nested candidate = %v (prefix %q), want util.go / ut", cands, prefix)
|
||||
}
|
||||
|
||||
// later arguments complete the same way as the first
|
||||
if cands, _ := complete("!diff main.go ma"); strings.Join(cands, ",") != "main.go,main_test.go" {
|
||||
t.Errorf("second argument = %v", cands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellCandidatesAbsoluteAndHome(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
if err := os.WriteFile(filepath.Join(home, "notes.txt"), []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shellTree(t)
|
||||
fakeCommands(t, "vi")
|
||||
|
||||
if cands, prefix := complete("!vi ~/no"); prefix != "no" ||
|
||||
strings.Join(cands, ",") != "notes.txt" {
|
||||
t.Errorf("~/ completion = %v (prefix %q)", cands, prefix)
|
||||
}
|
||||
|
||||
abs := filepath.Join(home, "no")
|
||||
if cands, prefix := complete("!vi " + abs); prefix != "no" ||
|
||||
strings.Join(cands, ",") != "notes.txt" {
|
||||
t.Errorf("absolute completion = %v (prefix %q)", cands, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShellCompleterDoContract is the part that would corrupt the line if it
|
||||
// were wrong: readline replaces the last `length` runes with a candidate, so
|
||||
// the candidates must be suffixes and the length must count runes.
|
||||
func TestShellCompleterDoContract(t *testing.T) {
|
||||
fakeCommands(t, "vim", "view")
|
||||
shellTree(t)
|
||||
c := completer()
|
||||
|
||||
line := []rune("!vi")
|
||||
got, length := c.Do(line, len(line))
|
||||
if length != 2 { // "vi" — the '!' is not part of the word
|
||||
t.Fatalf("length = %d, want 2", length)
|
||||
}
|
||||
// rebuilding the line from prefix + candidate must give the full word
|
||||
for i, g := range got {
|
||||
full := string(line[:len(line)-length]) + string(line[len(line)-length:]) + string(g)
|
||||
if full != "!vim" && full != "!view" {
|
||||
t.Errorf("candidate %d rebuilds to %q", i, full)
|
||||
}
|
||||
}
|
||||
|
||||
// a non-'!' line still goes to the builtin command tree (which appends its
|
||||
// own trailing space on a unique match)
|
||||
line = []rune("stat")
|
||||
got, length = c.Do(line, len(line))
|
||||
if length != 4 || len(got) == 0 || !strings.HasPrefix(string(got[0]), "us") {
|
||||
t.Errorf("builtin completion = %q, %d; want a candidate starting \"us\" at 4", got, length)
|
||||
}
|
||||
|
||||
// a multi-byte prefix must be measured in runes, not bytes
|
||||
if _, n := runeSuffixes([]string{"übermorgen"}, "üb"); n != 2 {
|
||||
t.Errorf("runeSuffixes length = %d, want 2 runes", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathExecutablesFindsRealBinaries checks the PATH scan against a directory
|
||||
// it controls: only files with an execute bit, no directories.
|
||||
func TestPathExecutablesFindsRealBinaries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "runnable"), []byte("#!/bin/sh\n"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "plainfile"), []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "subdir"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir)
|
||||
|
||||
// pathExecutables caches for the session, so exercise the scan directly
|
||||
var names []string
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
fi, err := e.Info()
|
||||
if err != nil || fi.IsDir() || fi.Mode().Perm()&0o111 == 0 {
|
||||
continue
|
||||
}
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
if strings.Join(names, ",") != "runnable" {
|
||||
t.Errorf("executable scan = %v, want only runnable", names)
|
||||
}
|
||||
}
|
||||
|
||||
// withAliases installs a fixed alias set for the test.
|
||||
func withAliases(t *testing.T, m map[string]string) {
|
||||
t.Helper()
|
||||
old := aliases
|
||||
aliases = m
|
||||
t.Cleanup(func() { aliases = old })
|
||||
}
|
||||
|
||||
// TestShellCandidatesThroughAlias: an alias that expands to a '!' escape turns
|
||||
// everything after its name into shell arguments, so it completes as such.
|
||||
func TestShellCandidatesThroughAlias(t *testing.T) {
|
||||
shellTree(t)
|
||||
fakeCommands(t, "vi", "ls")
|
||||
withAliases(t, map[string]string{
|
||||
"ll": "!ls -la",
|
||||
"e": "!vi $1",
|
||||
"co": "checkout $1", // a builtin, not a shell command
|
||||
"open": "!xdg-open $1", // shadows a builtin: must not count
|
||||
})
|
||||
|
||||
// arguments of a shell alias complete against the filesystem
|
||||
if cands, prefix := complete("ll ma"); prefix != "ma" ||
|
||||
strings.Join(cands, ",") != "main.go,main_test.go" {
|
||||
t.Errorf("alias argument = %v (prefix %q)", cands, prefix)
|
||||
}
|
||||
// including the empty one, which lists the directory
|
||||
if cands, _ := complete("e "); strings.Join(cands, ",") != "Makefile,main.go,main_test.go,src/" {
|
||||
t.Errorf("empty alias argument = %v", cands)
|
||||
}
|
||||
// and paths inside it
|
||||
if cands, prefix := complete("ll src/ut"); prefix != "ut" ||
|
||||
strings.Join(cands, ",") != "util.go" {
|
||||
t.Errorf("nested alias argument = %v (prefix %q)", cands, prefix)
|
||||
}
|
||||
|
||||
// the command word of an alias is fixed by its body, so PATH is never
|
||||
// offered — `ll vi` means the file "vi", not the editor
|
||||
if cands, _ := complete("ll vi"); len(cands) != 0 {
|
||||
t.Errorf("alias argument matched PATH: %v", cands)
|
||||
}
|
||||
|
||||
// an alias to a builtin is not a shell line at all
|
||||
if _, _, ok := shellLine("co ma"); ok {
|
||||
t.Error("an alias expanding to a builtin was treated as a shell line")
|
||||
}
|
||||
// nor is a name that a builtin owns, since runCommand never expands those
|
||||
if _, _, ok := shellLine("open ma"); ok {
|
||||
t.Error("a builtin name was resolved through an alias")
|
||||
}
|
||||
// nor an undefined name
|
||||
if _, _, ok := shellLine("nosuch ma"); ok {
|
||||
t.Error("an undefined alias was treated as a shell line")
|
||||
}
|
||||
// while the alias name itself is still being typed there is nothing to know
|
||||
if _, _, ok := shellLine("ll"); ok {
|
||||
t.Error("an incomplete alias name was resolved")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.0.42
|
||||
4.0.46
|
||||
|
||||
Reference in New Issue
Block a user