Compare commits
4
Commits
4.0.55
...
113527f220
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
113527f220 | ||
|
|
1e3ae9a4c7 | ||
|
|
b4797b056e | ||
|
|
9bc17513ed |
@@ -66,7 +66,7 @@ The commands available directly from the shell are `clone`, `init`, `log`,
|
|||||||
`pull`, `fetch`, `status`, `diff`, `overview`, `config`, `count`, `login` and
|
`pull`, `fetch`, `status`, `diff`, `overview`, `config`, `count`, `login` and
|
||||||
`cloneall`; every other command is interactive-only.
|
`cloneall`; every other command is interactive-only.
|
||||||
|
|
||||||
The interactive prompt is colored (Catppuccin Mocha) and shows the active
|
The interactive prompt is colored (Catppuccin-flavored) and shows the active
|
||||||
project, its git branch and a `*` dirty marker:
|
project, its git branch and a `*` dirty marker:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ func handleUnalias(name string) {
|
|||||||
|
|
||||||
func listAliases() {
|
func listAliases() {
|
||||||
if len(aliases) == 0 {
|
if len(aliases) == 0 {
|
||||||
fmt.Println(col(cGray, "no aliases defined"))
|
fmt.Println(col(cDark, "no aliases defined"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, n := range aliasNames() {
|
for _, n := range aliasNames() {
|
||||||
@@ -251,7 +251,7 @@ func listAliases() {
|
|||||||
|
|
||||||
// formatAlias renders `name = 'body'` with color.
|
// formatAlias renders `name = 'body'` with color.
|
||||||
func formatAlias(name, body string) string {
|
func formatAlias(name, body string) string {
|
||||||
return col(cGreen, name) + col(cGray, " = ") + col(cYellow, "'"+body+"'")
|
return col(cGreen, name) + col(cDark, " = ") + col(cYellow, "'"+body+"'")
|
||||||
}
|
}
|
||||||
|
|
||||||
// expandAlias substitutes positional parameters in an alias body. With no
|
// expandAlias substitutes positional parameters in an alias body. With no
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ func (r *remoteAPI) doUpload(method, endpoint, ctype string, body io.Reader, siz
|
|||||||
func (r *remoteAPI) uploadAssets(owner, repo string, ref releaseRef, assets []releaseAsset) error {
|
func (r *remoteAPI) uploadAssets(owner, repo string, ref releaseRef, assets []releaseAsset) error {
|
||||||
failed := 0
|
failed := 0
|
||||||
for _, a := range assets {
|
for _, a := range assets {
|
||||||
fmt.Printf(" %s %s %s\n", col(cGray, "uploading"),
|
fmt.Printf(" %s %s %s\n", col(cDark, "uploading"),
|
||||||
col(cGreen, padRight(a.name, 28)), col(cGray, humanSize(a.size)))
|
col(cGreen, padRight(a.name, 28)), col(cDark, humanSize(a.size)))
|
||||||
if err := r.uploadAsset(owner, repo, ref, a); err != nil {
|
if err := r.uploadAsset(owner, repo, ref, a); err != nil {
|
||||||
errorln(" " + a.name + ": " + err.Error())
|
errorln(" " + a.name + ": " + err.Error())
|
||||||
failed++
|
failed++
|
||||||
|
|||||||
@@ -7,21 +7,24 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Colors for the prompt, banner and output, using the Catppuccin Mocha palette
|
// Colors for the prompt, banner and output, as 24-bit truecolor escapes: the
|
||||||
// as 24-bit truecolor escapes
|
// Catppuccin Mocha accents with a warm white and three greys of its own.
|
||||||
// (https://terminalcolors.com/themes/catppuccin/mocha/).
|
|
||||||
const (
|
const (
|
||||||
cReset = "\033[0m"
|
cReset = "\033[0m"
|
||||||
cBold = "\033[1m"
|
cBold = "\033[1m"
|
||||||
cDim = "\033[2m"
|
cDim = "\033[2m"
|
||||||
|
|
||||||
cGreen = "\033[38;2;166;227;161m" // Green #a6e3a1
|
cYellow = "\033[38;2;249;226;175m" // #f9e2af
|
||||||
cYellow = "\033[38;2;249;226;175m" // Yellow #f9e2af
|
cOrange = "\033[38;2;250;179;135m" // #fab387
|
||||||
cRed = "\033[38;2;243;139;168m" // Red #f38ba8
|
cRed = "\033[38;2;243;139;168m" // #f38ba8
|
||||||
cCyan = "\033[38;2;148;226;213m" // Teal #94e2d5
|
cGreen = "\033[38;2;148;226;213m" // #94e2d5
|
||||||
cPurple = "\033[38;2;203;166;247m" // Mauve #cba6f7
|
cBlue = "\033[38;2;180;190;254m" // #b4befe
|
||||||
cWhite = "\033[38;2;205;214;244m" // Text #cdd6f4
|
cPink = "\033[38;2;245;178;247m" // #f5b2f7
|
||||||
cGray = "\033[38;2;108;112;134m" // Overlay0 #6c7086
|
cViolet = "\033[38;2;203;166;247m" // #cba6f7
|
||||||
|
cWhite = "\033[38;2;240;240;234m" // #f0f0ea
|
||||||
|
cGrey = "\033[38;2;170;170;187m" // #aaaabb
|
||||||
|
cDark = "\033[38;2;119;119;136m" // #777788
|
||||||
|
cDarker = "\033[38;2;68;68;85m" // #444455
|
||||||
)
|
)
|
||||||
|
|
||||||
// col wraps s in color c, but only when color output is enabled.
|
// col wraps s in color c, but only when color output is enabled.
|
||||||
@@ -66,7 +69,7 @@ func formatRepoList(entries []lsEntry, withSize bool) string {
|
|||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
fmt.Fprintf(&b, " %s %s", col(cGreen, padRight(e.name, width)), col(cYellow, e.date))
|
fmt.Fprintf(&b, " %s %s", col(cGreen, padRight(e.name, width)), col(cYellow, e.date))
|
||||||
if withSize {
|
if withSize {
|
||||||
fmt.Fprintf(&b, " %s", col(cGray, fmt.Sprintf("%7s", humanSize(e.size))))
|
fmt.Fprintf(&b, " %s", col(cDark, fmt.Sprintf("%7s", humanSize(e.size))))
|
||||||
}
|
}
|
||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -282,7 +282,7 @@ func runCommandDepth(line string, depth int) bool {
|
|||||||
if pat != "" {
|
if pat != "" {
|
||||||
what = "no " + many + " matching '" + word(words, 1) + "'"
|
what = "no " + many + " matching '" + word(words, 1) + "'"
|
||||||
}
|
}
|
||||||
fmt.Println(col(cGray, what))
|
fmt.Println(col(cDark, what))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// no size column when the server gave no usable sizes, rather than a
|
// no size column when the server gave no usable sizes, rather than a
|
||||||
@@ -296,7 +296,7 @@ func runCommandDepth(line string, depth int) bool {
|
|||||||
if total > 0 {
|
if total > 0 {
|
||||||
summary += " · " + humanSize(total)
|
summary += " · " + humanSize(total)
|
||||||
}
|
}
|
||||||
fmt.Println(col(cGray, summary))
|
fmt.Println(col(cDark, summary))
|
||||||
|
|
||||||
case "show": // show a repository's log directly on the server
|
case "show": // show a repository's log directly on the server
|
||||||
prj := PRJ
|
prj := PRJ
|
||||||
@@ -610,7 +610,7 @@ func runCommandDepth(line string, depth int) bool {
|
|||||||
|
|
||||||
default: // unknown command — no longer forwarded to the shell
|
default: // unknown command — no longer forwarded to the shell
|
||||||
fmt.Println(col(cRed, "unknown command: "+words[0]) +
|
fmt.Println(col(cRed, "unknown command: "+words[0]) +
|
||||||
col(cGray, " (prefix with '!' to run a shell command)"))
|
col(cDark, " (prefix with '!' to run a shell command)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -671,9 +671,9 @@ func formatLog(lines []string, now time.Time) string {
|
|||||||
n++
|
n++
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(&full, "%s %s %s\n", col(cPurple, hash), col(cYellow, z), subj)
|
fmt.Fprintf(&full, "%s %s %s\n", col(cViolet, hash), col(cYellow, z), subj)
|
||||||
fmt.Fprintf(&short, "%s %s %s\n", col(cPurple, hash), col(cYellow, zs), subj)
|
fmt.Fprintf(&short, "%s %s %s\n", col(cViolet, hash), col(cYellow, zs), subj)
|
||||||
fmt.Fprintf(&tiny, "%s %s %s\n", col(cPurple, hash), col(cYellow, zss), subj)
|
fmt.Fprintf(&tiny, "%s %s %s\n", col(cViolet, hash), col(cYellow, zss), subj)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -188,9 +188,9 @@ func migrateRemoteKeys(path, data string) {
|
|||||||
errorln("could not update " + path + ": " + err.Error())
|
errorln("could not update " + path + ": " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println(col(cGray, path+": mirror settings renamed to the remote.<name>.* form"))
|
fmt.Println(col(cDark, path+": mirror settings renamed to the remote.<name>.* form"))
|
||||||
for _, r := range renamed {
|
for _, r := range renamed {
|
||||||
fmt.Println(col(cGray, " "+r))
|
fmt.Println(col(cDark, " "+r))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
// input.go — the y/n prompts that guard the destructive commands.
|
||||||
|
//
|
||||||
|
// These questions are the only thing standing between `init` and a wiped
|
||||||
|
// server repository, so the terminal handling here has to be exactly right: a
|
||||||
|
// question that cannot be answered is worse than no question at all.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,50 +19,86 @@ import (
|
|||||||
// variable because it guards the destructive operations: tests replace it to
|
// variable because it guards the destructive operations: tests replace it to
|
||||||
// drive those paths without a terminal, and to assert that the question was
|
// drive those paths without a terminal, and to assert that the question was
|
||||||
// asked at all.
|
// asked at all.
|
||||||
|
//
|
||||||
|
// When the keypress cannot be read at all the answer is no, whatever the
|
||||||
|
// default says — a question nobody saw must never be taken as consent.
|
||||||
var yesno = func(prompt string, def bool) bool {
|
var yesno = func(prompt string, def bool) bool {
|
||||||
suffix := " y/N ? "
|
suffix := " y/N ? "
|
||||||
if def {
|
if def {
|
||||||
suffix = " Y/n ? "
|
suffix = " Y/n ? "
|
||||||
}
|
}
|
||||||
ans := strings.ToLower(strings.TrimSpace(getkey(prompt + suffix)))
|
key, ok := getkey(prompt + suffix)
|
||||||
|
if !ok {
|
||||||
|
errorln("could not read an answer from the terminal — assuming no")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ans := strings.ToLower(strings.TrimSpace(key))
|
||||||
if ans == "" {
|
if ans == "" {
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
return ans == "y"
|
return ans == "y"
|
||||||
}
|
}
|
||||||
|
|
||||||
// getkey reads a single keypress from the terminal without echo. It reads a
|
// keyModeArgs put the terminal into single-key input: one keypress is delivered
|
||||||
// single byte directly from stdin; readline is not reading at this point (we
|
// as it is typed, and it is not echoed.
|
||||||
// are inside command execution), so there is no reader to desync with.
|
//
|
||||||
|
// MIN and TIME are set explicitly, and that is not decoration. They are not
|
||||||
|
// part of the canonical/non-canonical switch — they live in their own slots of
|
||||||
|
// the control-character array and survive `stty icanon`. drainTTY leaves them
|
||||||
|
// at "min 0 time 0" ("return what is buffered, do not wait"), so without this
|
||||||
|
// the *second* question of a session read zero bytes, answered itself with its
|
||||||
|
// default, and left the keypress queued for the next prompt line.
|
||||||
|
var keyModeArgs = []string{"-icanon", "-echo", "min", "1", "time", "0"}
|
||||||
|
|
||||||
|
// getkey reads a single keypress from the terminal without echo, and reports
|
||||||
|
// whether it got one. It reads a single byte directly from stdin; readline only
|
||||||
|
// reads while it is inside Readline(), and we are inside command execution
|
||||||
|
// here, so there is no reader to desync with.
|
||||||
//
|
//
|
||||||
// Anything else already typed on the same line is discarded: answering "yes"
|
// 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,
|
// 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.
|
// where it would come back as a bogus command.
|
||||||
func getkey(prompt string) string {
|
func getkey(prompt string) (string, bool) {
|
||||||
fmt.Print(prompt)
|
fmt.Print(prompt)
|
||||||
tty := readline.IsTerminal(int(os.Stdin.Fd()))
|
|
||||||
if tty {
|
restore := func() {}
|
||||||
stty("-icanon", "-echo")
|
if stdinIsTTY() {
|
||||||
|
restore = singleKeyMode()
|
||||||
}
|
}
|
||||||
var buf [1]byte
|
var buf [1]byte
|
||||||
n, err := os.Stdin.Read(buf[:])
|
n, err := os.Stdin.Read(buf[:])
|
||||||
if tty {
|
restore()
|
||||||
drainTTY()
|
|
||||||
stty("icanon", "echo")
|
if err != nil || n == 0 {
|
||||||
}
|
fmt.Println()
|
||||||
key := ""
|
return "", false
|
||||||
if err == nil && n > 0 {
|
|
||||||
key = strings.Trim(string(buf[:n]), "\r\n\t")
|
|
||||||
}
|
}
|
||||||
|
key := strings.Trim(string(buf[:n]), "\r\n\t")
|
||||||
fmt.Println(key)
|
fmt.Println(key)
|
||||||
return key
|
return key, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// singleKeyMode switches the terminal to single-key input and returns the
|
||||||
|
// function that puts it back. The previous settings are restored verbatim from
|
||||||
|
// `stty -g` rather than by naming the flags we changed: naming them is how the
|
||||||
|
// MIN/TIME above were left behind in the first place, and mgsh should hand the
|
||||||
|
// terminal back exactly as it found it.
|
||||||
|
func singleKeyMode() func() {
|
||||||
|
saved, err := sttyRun("-g")
|
||||||
|
saved = strings.TrimSpace(saved)
|
||||||
|
sttyRun(keyModeArgs...)
|
||||||
|
|
||||||
|
if err != nil || saved == "" {
|
||||||
|
return func() { drainTTY(); sttyRun("icanon", "echo") } // best effort
|
||||||
|
}
|
||||||
|
return func() { drainTTY(); sttyRun(strings.Fields(saved)...) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// drainTTY discards input already queued on the terminal. `min 0 time 0` makes
|
// 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
|
// a read return whatever is buffered without waiting, so this cannot block when
|
||||||
// nothing is pending.
|
// nothing is pending. Its caller restores the terminal afterwards.
|
||||||
func drainTTY() {
|
func drainTTY() {
|
||||||
stty("-icanon", "-echo", "min", "0", "time", "0")
|
sttyRun("-icanon", "-echo", "min", "0", "time", "0")
|
||||||
buf := make([]byte, 256)
|
buf := make([]byte, 256)
|
||||||
for {
|
for {
|
||||||
n, err := os.Stdin.Read(buf)
|
n, err := os.Stdin.Read(buf)
|
||||||
@@ -66,10 +108,16 @@ func drainTTY() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stty(args ...string) {
|
// stdinIsTTY reports whether keypresses come from a terminal. A variable so the
|
||||||
|
// tests can exercise the terminal path without one.
|
||||||
|
var stdinIsTTY = func() bool { return readline.IsTerminal(int(os.Stdin.Fd())) }
|
||||||
|
|
||||||
|
// sttyRun runs stty on the terminal and returns its output. Errors are silent:
|
||||||
|
// every caller has a fallback, and a stray "stty: ..." line in the middle of a
|
||||||
|
// half-printed question helps nobody.
|
||||||
|
var sttyRun = func(args ...string) (string, error) {
|
||||||
c := exec.Command("stty", args...)
|
c := exec.Command("stty", args...)
|
||||||
c.Stdin = os.Stdin
|
c.Stdin = os.Stdin
|
||||||
c.Stdout = os.Stdout
|
out, err := c.Output()
|
||||||
c.Stderr = os.Stderr
|
return string(out), err
|
||||||
c.Run()
|
|
||||||
}
|
}
|
||||||
|
|||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// input_test.go — the y/n prompt is the last thing between `init` and a wiped
|
||||||
|
// server repository, so the terminal handling around it is pinned here.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeTTY stands in for the terminal driver. It models the one detail that made
|
||||||
|
// the skipped-question bug possible: MIN and TIME are not part of the
|
||||||
|
// canonical/non-canonical switch, so they survive `stty icanon` and carry over
|
||||||
|
// into the next prompt.
|
||||||
|
type fakeTTY struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
state map[string]string
|
||||||
|
calls [][]string
|
||||||
|
ready chan struct{} // signalled once single-key mode is in effect
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeTTY() *fakeTTY {
|
||||||
|
return &fakeTTY{
|
||||||
|
state: map[string]string{"icanon": "on", "echo": "on", "min": "1", "time": "0"},
|
||||||
|
ready: make(chan struct{}, 4),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTTY) run(args ...string) (string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, args)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case len(args) == 1 && args[0] == "-g":
|
||||||
|
return f.serializeLocked(), nil
|
||||||
|
case len(args) == 1 && strings.HasPrefix(args[0], "saved:"):
|
||||||
|
for _, kv := range strings.Split(strings.TrimPrefix(args[0], "saved:"), ",") {
|
||||||
|
if k, v, ok := strings.Cut(kv, "="); ok {
|
||||||
|
f.state[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch a := args[i]; a {
|
||||||
|
case "min", "time":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
f.state[a] = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
f.state[strings.TrimPrefix(a, "-")] = boolWord(!strings.HasPrefix(a, "-"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(args, " ") == strings.Join(keyModeArgs, " ") {
|
||||||
|
f.ready <- struct{}{} // single-key mode is set; the read comes next
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolWord(on bool) string {
|
||||||
|
if on {
|
||||||
|
return "on"
|
||||||
|
}
|
||||||
|
return "off"
|
||||||
|
}
|
||||||
|
|
||||||
|
// serializeLocked renders the settings as one token, the way `stty -g` does.
|
||||||
|
func (f *fakeTTY) serializeLocked() string {
|
||||||
|
return "saved:icanon=" + f.state["icanon"] + ",echo=" + f.state["echo"] +
|
||||||
|
",min=" + f.state["min"] + ",time=" + f.state["time"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTTY) get(k string) string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.state[k]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTTY) snapshot() string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.serializeLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// installFakeTTY points getkey at the fake terminal for the duration of a test.
|
||||||
|
func installFakeTTY(t *testing.T) *fakeTTY {
|
||||||
|
t.Helper()
|
||||||
|
f := newFakeTTY()
|
||||||
|
oldRun, oldIsTTY, oldStdin := sttyRun, stdinIsTTY, os.Stdin
|
||||||
|
sttyRun = f.run
|
||||||
|
stdinIsTTY = func() bool { return true }
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sttyRun, stdinIsTTY, os.Stdin = oldRun, oldIsTTY, oldStdin
|
||||||
|
})
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// askOnce runs one getkey against the fake terminal, answering with keys once
|
||||||
|
// the terminal is actually in single-key mode. It reports the MIN in effect at
|
||||||
|
// the moment of the read — the value the old code got wrong.
|
||||||
|
func askOnce(t *testing.T, f *fakeTTY, keys string) (key string, ok bool, minAtRead string) {
|
||||||
|
t.Helper()
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.Stdin = r
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
key, ok = getkey("question? ")
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-f.ready:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("the terminal was never put into single-key mode")
|
||||||
|
}
|
||||||
|
minAtRead = f.get("min")
|
||||||
|
w.WriteString(keys)
|
||||||
|
w.Close() // so the drain that follows the keypress sees EOF instead of blocking
|
||||||
|
<-done
|
||||||
|
r.Close()
|
||||||
|
return key, ok, minAtRead
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEveryQuestionWaitsForAnAnswer is the regression test for the bug where the
|
||||||
|
// second y/n question of a session was skipped: drainTTY left the terminal at
|
||||||
|
// "min 0" ("return what is buffered, do not wait"), the restore only named
|
||||||
|
// icanon and echo, and so the next read returned zero bytes and answered the
|
||||||
|
// question with its default.
|
||||||
|
func TestEveryQuestionWaitsForAnAnswer(t *testing.T) {
|
||||||
|
f := installFakeTTY(t)
|
||||||
|
|
||||||
|
for i, want := range []string{"y", "n", "y"} {
|
||||||
|
key, ok, minAtRead := askOnce(t, f, want)
|
||||||
|
if !ok || key != want {
|
||||||
|
t.Fatalf("question %d: got (%q, %v), want (%q, true)", i+1, key, ok, want)
|
||||||
|
}
|
||||||
|
if minAtRead != "1" {
|
||||||
|
t.Errorf("question %d read the terminal at min=%s, want min=1 — "+
|
||||||
|
"min 0 returns without waiting and answers the question by itself",
|
||||||
|
i+1, minAtRead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPromptHandsTheTerminalBackUnchanged: mgsh must not leave the terminal in
|
||||||
|
// a mode it chose. The old restore named only icanon and echo and left "min 0"
|
||||||
|
// behind, which outlived mgsh itself and broke the next program's single-key
|
||||||
|
// reads too.
|
||||||
|
func TestPromptHandsTheTerminalBackUnchanged(t *testing.T) {
|
||||||
|
f := installFakeTTY(t)
|
||||||
|
initial := f.snapshot()
|
||||||
|
|
||||||
|
if _, ok, _ := askOnce(t, f, "y"); !ok {
|
||||||
|
t.Fatal("getkey did not read the key")
|
||||||
|
}
|
||||||
|
if got := f.snapshot(); got != initial {
|
||||||
|
t.Errorf("terminal left as %s, want it back at %s", got, initial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPromptRestoresFromTheSavedState pins *how* the terminal is restored: from
|
||||||
|
// the state captured with `stty -g`, not by naming the flags we changed.
|
||||||
|
func TestPromptRestoresFromTheSavedState(t *testing.T) {
|
||||||
|
f := installFakeTTY(t)
|
||||||
|
if _, ok, _ := askOnce(t, f, "y"); !ok {
|
||||||
|
t.Fatal("getkey did not read the key")
|
||||||
|
}
|
||||||
|
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if len(f.calls) == 0 {
|
||||||
|
t.Fatal("stty was never called")
|
||||||
|
}
|
||||||
|
if first := f.calls[0]; len(first) != 1 || first[0] != "-g" {
|
||||||
|
t.Errorf("first stty call was %v, want [-g]: the state has to be captured "+
|
||||||
|
"before it is changed", first)
|
||||||
|
}
|
||||||
|
last := f.calls[len(f.calls)-1]
|
||||||
|
if len(last) != 1 || !strings.HasPrefix(last[0], "saved:") {
|
||||||
|
t.Errorf("last stty call was %v, want the saved state played back", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSingleKeyModeSetsMinExplicitly: MIN and TIME are inherited, so they have
|
||||||
|
// to be named on the way in. Without that, a terminal left at "min 0" by an
|
||||||
|
// earlier program (or an earlier mgsh) skips the question.
|
||||||
|
func TestSingleKeyModeSetsMinExplicitly(t *testing.T) {
|
||||||
|
args := strings.Join(keyModeArgs, " ")
|
||||||
|
for _, want := range []string{"-icanon", "-echo", "min 1", "time 0"} {
|
||||||
|
if !strings.Contains(args, want) {
|
||||||
|
t.Errorf("single-key mode is %q, missing %q", args, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuestionSkippedByAPoisonedTerminalIsNotAYes: even if the terminal is
|
||||||
|
// already in the broken state when mgsh starts, the answer must not be the
|
||||||
|
// default — nobody saw the question, so nobody agreed to anything.
|
||||||
|
func TestQuestionSkippedByAPoisonedTerminalIsNotAYes(t *testing.T) {
|
||||||
|
oldRun, oldIsTTY, oldStdin := sttyRun, stdinIsTTY, os.Stdin
|
||||||
|
defer func() { sttyRun, stdinIsTTY, os.Stdin = oldRun, oldIsTTY, oldStdin }()
|
||||||
|
|
||||||
|
sttyRun = func(args ...string) (string, error) { return "", nil }
|
||||||
|
stdinIsTTY = func() bool { return true }
|
||||||
|
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
w.Close() // a terminal that returns nothing at all
|
||||||
|
os.Stdin = r
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
if key, ok := getkey("question? "); ok {
|
||||||
|
t.Fatalf("getkey reported a key %q from a terminal that gave none", key)
|
||||||
|
}
|
||||||
|
if yesno("destroy everything?", true) {
|
||||||
|
t.Error("an unanswerable question was taken as yes")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -109,7 +109,7 @@ func TestFormatRepoList(t *testing.T) {
|
|||||||
colored := formatRepoList(entries, false)
|
colored := formatRepoList(entries, false)
|
||||||
useColor = false
|
useColor = false
|
||||||
strip := func(s string) string {
|
strip := func(s string) string {
|
||||||
for _, c := range []string{cReset, cGreen, cYellow, cGray} {
|
for _, c := range []string{cReset, cGreen, cYellow, cDark} {
|
||||||
s = strings.ReplaceAll(s, c, "")
|
s = strings.ReplaceAll(s, c, "")
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
|
|||||||
+7
-7
@@ -82,7 +82,7 @@ func overviewAll() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
fmt.Println(col(cGray, "nothing under "+BASE))
|
fmt.Println(col(cDark, "nothing under "+BASE))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,9 +109,9 @@ func overviewAll() {
|
|||||||
if initN > 0 {
|
if initN > 0 {
|
||||||
summary += fmt.Sprintf(" · %d to init", initN)
|
summary += fmt.Sprintf(" · %d to init", initN)
|
||||||
}
|
}
|
||||||
fmt.Println(col(cGray, summary))
|
fmt.Println(col(cDark, summary))
|
||||||
if srv.err != nil {
|
if srv.err != nil {
|
||||||
fmt.Println(col(cGray, " git server not reachable — local view only"))
|
fmt.Println(col(cDark, " git server not reachable — local view only"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@ func syncColor(s projStatus) string {
|
|||||||
case s.ahead > 0:
|
case s.ahead > 0:
|
||||||
return cGreen
|
return cGreen
|
||||||
default:
|
default:
|
||||||
return cGray
|
return cDark
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,8 +339,8 @@ func formatProjStatus(s projStatus, w overviewWidths) string {
|
|||||||
if !s.lastWhen.IsZero() {
|
if !s.lastWhen.IsZero() {
|
||||||
age = shortAge(time.Since(s.lastWhen))
|
age = shortAge(time.Since(s.lastWhen))
|
||||||
}
|
}
|
||||||
b.WriteString(" " + col(cGray, padRight(s.lastHost, w.host)))
|
b.WriteString(" " + col(cDark, padRight(s.lastHost, w.host)))
|
||||||
b.WriteString(" " + col(cGray, fmt.Sprintf("%4s", age)))
|
b.WriteString(" " + col(cDark, fmt.Sprintf("%4s", age)))
|
||||||
}
|
}
|
||||||
if w.hint {
|
if w.hint {
|
||||||
hint := ""
|
hint := ""
|
||||||
@@ -350,7 +350,7 @@ func formatProjStatus(s projStatus, w overviewWidths) string {
|
|||||||
b.WriteString(" " + col(cYellow, padRight(hint, hintWidth)))
|
b.WriteString(" " + col(cYellow, padRight(hint, hintWidth)))
|
||||||
}
|
}
|
||||||
if len(s.mirrors) > 0 {
|
if len(s.mirrors) > 0 {
|
||||||
b.WriteString(col(cGray, " → "+strings.Join(s.mirrors, " ")))
|
b.WriteString(col(cDark, " → "+strings.Join(s.mirrors, " ")))
|
||||||
}
|
}
|
||||||
return strings.TrimRight(b.String(), " ")
|
return strings.TrimRight(b.String(), " ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,21 +45,21 @@ func plainPrompt() string {
|
|||||||
|
|
||||||
func coloredPrompt() string {
|
func coloredPrompt() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(cBold + cPurple + "< " + cReset)
|
b.WriteString(cBold + cViolet + "< " + cReset)
|
||||||
b.WriteString(cCyan + filepath.Base(BASE) + cReset)
|
b.WriteString(cBlue + filepath.Base(BASE) + cReset)
|
||||||
if PRJ != "" && isDir(BASE+"/"+PRJ) {
|
if PRJ != "" && isDir(BASE+"/"+PRJ) {
|
||||||
b.WriteString(cBold + cWhite + "/" + cReset + cBold + cGreen + PRJ + cReset)
|
b.WriteString(cBold + cWhite + "/" + cReset + cBold + cGreen + PRJ + cReset)
|
||||||
if BPLSTATE != "" {
|
if BPLSTATE != "" {
|
||||||
b.WriteString(cRed + BPLSTATE + cReset)
|
b.WriteString(cRed + BPLSTATE + cReset)
|
||||||
}
|
}
|
||||||
if BRANCH != "" {
|
if BRANCH != "" {
|
||||||
b.WriteString(cGray + " (" + cReset + cCyan + BRANCH + cReset)
|
b.WriteString(cDark + " (" + cReset + cBlue + BRANCH + cReset)
|
||||||
if DIRTY {
|
if DIRTY {
|
||||||
b.WriteString(cRed + "*" + cReset)
|
b.WriteString(cRed + "*" + cReset)
|
||||||
}
|
}
|
||||||
b.WriteString(cGray + ")" + cReset)
|
b.WriteString(cDark + ")" + cReset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.WriteString(" " + cBold + cPurple + ">" + cReset + " ")
|
b.WriteString(" " + cBold + cViolet + ">" + cReset + " ")
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -193,8 +193,8 @@ func handleRelease(args string) {
|
|||||||
errorln("skipping " + s + ": another directory already contributes that name")
|
errorln("skipping " + s + ": another directory already contributes that name")
|
||||||
}
|
}
|
||||||
if len(assets) > 0 {
|
if len(assets) > 0 {
|
||||||
fmt.Printf("%s %s %s\n", col(cGray, "attaching"), col(cYellow, assetSummary(assets)),
|
fmt.Printf("%s %s %s\n", col(cDark, "attaching"), col(cYellow, assetSummary(assets)),
|
||||||
col(cGray, "from "+strings.Join(assetDirList(DIR), " and ")))
|
col(cDark, "from "+strings.Join(assetDirList(DIR), " and ")))
|
||||||
}
|
}
|
||||||
|
|
||||||
done := 0
|
done := 0
|
||||||
@@ -204,7 +204,7 @@ func handleRelease(args string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(targets) > 1 {
|
if len(targets) > 1 {
|
||||||
fmt.Printf("%s %d/%d remotes released\n", col(cGray, "release:"), done, len(targets))
|
fmt.Printf("%s %d/%d remotes released\n", col(cDark, "release:"), done, len(targets))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,8 +303,8 @@ func publishRelease(t RemoteTarget, repo, tag, body string, assets []releaseAsse
|
|||||||
if rel.Prerelease {
|
if rel.Prerelease {
|
||||||
what += " (pre-release)"
|
what += " (pre-release)"
|
||||||
}
|
}
|
||||||
fmt.Printf("%s %s %s %s\n", col(cGray, "remote"), col(cYellow, t.Name),
|
fmt.Printf("%s %s %s %s\n", col(cDark, "remote"), col(cYellow, t.Name),
|
||||||
col(cGreen, what), col(cCyan, api.repoWebURL(owner, repo)))
|
col(cGreen, what), col(cBlue, api.repoWebURL(owner, repo)))
|
||||||
|
|
||||||
if len(assets) > 0 {
|
if len(assets) > 0 {
|
||||||
if err := api.uploadAssets(owner, repo, ref, assets); err != nil {
|
if err := api.uploadAssets(owner, repo, ref, assets); err != nil {
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ func handlePushRemote(args string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(targets) > 1 {
|
if len(targets) > 1 {
|
||||||
fmt.Printf("%s %d/%d remotes updated\n", col(cGray, "pushremote:"), done, len(targets))
|
fmt.Printf("%s %d/%d remotes updated\n", col(cDark, "pushremote:"), done, len(targets))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +313,7 @@ func pushToRemote(t RemoteTarget, repo, description string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
fmt.Printf("%s %s %s (as %s)\n",
|
fmt.Printf("%s %s %s (as %s)\n",
|
||||||
col(cGray, "remote"), col(cYellow, t.Name), col(cCyan, api.url), col(cGreen, owner))
|
col(cDark, "remote"), col(cYellow, t.Name), col(cBlue, api.url), col(cGreen, owner))
|
||||||
|
|
||||||
exists, err := api.repoExists(owner, repo)
|
exists, err := api.repoExists(owner, repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -343,7 +343,7 @@ func pushToRemote(t RemoteTarget, repo, description string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
gitPushHeader(DIR, t.Name, header, "--tags")
|
gitPushHeader(DIR, t.Name, header, "--tags")
|
||||||
fmt.Println(col(cGreen, "pushed to ") + col(cCyan, web))
|
fmt.Println(col(cGreen, "pushed to ") + col(cBlue, web))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -191,9 +191,9 @@ func secretsApproved(dir string) bool {
|
|||||||
where += ":" + strconv.Itoa(h.lineNo)
|
where += ":" + strconv.Itoa(h.lineNo)
|
||||||
}
|
}
|
||||||
fmt.Printf(" %s %s\n %s\n",
|
fmt.Printf(" %s %s\n %s\n",
|
||||||
col(cYellow, where), col(cGray, h.kind), col(cRed, ellipsis(strings.TrimSpace(h.text), 100)))
|
col(cYellow, where), col(cDark, h.kind), col(cRed, ellipsis(strings.TrimSpace(h.text), 100)))
|
||||||
}
|
}
|
||||||
fmt.Println(col(cGray, " (set 'secretscan = off' to skip this check)"))
|
fmt.Println(col(cDark, " (set 'secretscan = off' to skip this check)"))
|
||||||
return yesno("push anyway?", false)
|
return yesno("push anyway?", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -24,11 +24,11 @@ func showConfig() {
|
|||||||
project = DIR + "/" + projectRC
|
project = DIR + "/" + projectRC
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%s %s\n", col(cGray, "global "), col(cCyan, global))
|
fmt.Printf("%s %s\n", col(cDark, "global "), col(cBlue, global))
|
||||||
if project != "" {
|
if project != "" {
|
||||||
fmt.Printf("%s %s\n", col(cGray, "project"), col(cCyan, project))
|
fmt.Printf("%s %s\n", col(cDark, "project"), col(cBlue, project))
|
||||||
} else if PRJ != "" {
|
} else if PRJ != "" {
|
||||||
fmt.Printf("%s %s\n", col(cGray, "project"), col(cGray, "no "+projectRC+" in "+PRJ))
|
fmt.Printf("%s %s\n", col(cDark, "project"), col(cDark, "no "+projectRC+" in "+PRJ))
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
@@ -72,13 +72,13 @@ func showConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if k := sshKeyPath(); k != "" {
|
if k := sshKeyPath(); k != "" {
|
||||||
fmt.Printf(" %s%s\n", col(cGray, padRight("ssh identity", 14)), col(cGray, k))
|
fmt.Printf(" %s%s\n", col(cDark, padRight("ssh identity", 14)), col(cDark, k))
|
||||||
}
|
}
|
||||||
fmt.Printf(" %s%s\n", col(cGray, padRight("clone url", 14)), col(cGray, URL))
|
fmt.Printf(" %s%s\n", col(cDark, padRight("clone url", 14)), col(cDark, URL))
|
||||||
// the project's real origin: it can differ from what the current settings
|
// 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
|
// would produce, e.g. after moving the server or editing a project .mgshrc
|
||||||
if o := originURL(); o != "" {
|
if o := originURL(); o != "" {
|
||||||
fmt.Printf(" %s%s\n", col(cGray, padRight("origin", 14)), col(cGray, o))
|
fmt.Printf(" %s%s\n", col(cDark, padRight("origin", 14)), col(cDark, o))
|
||||||
}
|
}
|
||||||
|
|
||||||
showRemotes()
|
showRemotes()
|
||||||
@@ -90,11 +90,11 @@ func showRemotes() {
|
|||||||
targets, incomplete := cfg.mirrorTargets()
|
targets, incomplete := cfg.mirrorTargets()
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
if len(targets) == 0 && len(incomplete) == 0 {
|
if len(targets) == 0 && len(incomplete) == 0 {
|
||||||
fmt.Println(col(cGray, "no pushremote targets configured"))
|
fmt.Println(col(cDark, "no pushremote targets configured"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(col(cGray, "pushremote targets (in push order):"))
|
fmt.Println(col(cDark, "pushremote targets (in push order):"))
|
||||||
for _, t := range targets {
|
for _, t := range targets {
|
||||||
vis := "private"
|
vis := "private"
|
||||||
if strings.EqualFold(strings.TrimSpace(t.Vis), "public") {
|
if strings.EqualFold(strings.TrimSpace(t.Vis), "public") {
|
||||||
@@ -106,7 +106,7 @@ func showRemotes() {
|
|||||||
}
|
}
|
||||||
fmt.Printf(" %s%s %s\n",
|
fmt.Printf(" %s%s %s\n",
|
||||||
col(cGreen, padRight("@"+t.Name, 14)), t.URL,
|
col(cGreen, padRight("@"+t.Name, 14)), t.URL,
|
||||||
col(cGray, kind+", "+vis+", key "+maskSecret(t.Key)))
|
col(cDark, kind+", "+vis+", key "+maskSecret(t.Key)))
|
||||||
}
|
}
|
||||||
for _, n := range incomplete {
|
for _, n := range incomplete {
|
||||||
fmt.Printf(" %s%s\n", col(cRed, padRight("@"+n, 14)), col(cRed, "incomplete: url or key missing"))
|
fmt.Printf(" %s%s\n", col(cRed, padRight("@"+n, 14)), col(cRed, "incomplete: url or key missing"))
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.0.55
|
4.0.62
|
||||||
|
|||||||
Reference in New Issue
Block a user