From e0b42ab8d9099cedf686dfbf3f0e88a2d2b19a1b Mon Sep 17 00:00:00 2001 From: Andrew A Date: Sun, 7 Jun 2026 20:19:52 +0200 Subject: [PATCH 1/2] feat: run will also copy/seed The current recommended way to test without relying on hooks directly is to switch branches with hooks suppressed, and then use `bight run`. Prior to these changes, this did not apply to the recenly added `copy` directive. These changes extend `bight run` to prompt the user if they would like to run copy, so long as the destination file doesn't already exist. Also extracts a "prompt" helper that allows taking the default in non-interactive mode. The interactive setup command has been updated to use this same helper. Previously, it was duplicating these checks inline. --- cmd/install.go | 26 +++++---------- cmd/prompt.go | 58 ++++++++++++++++++++++++++++++++ cmd/prompt_test.go | 82 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 cmd/prompt.go create mode 100644 cmd/prompt_test.go diff --git a/cmd/install.go b/cmd/install.go index 1afc982..6ce2e89 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -1,7 +1,6 @@ package cmd import ( - "bufio" "errors" "fmt" "os" @@ -35,12 +34,7 @@ func promptInitConfig() error { return err } - reader := bufio.NewReader(os.Stdin) - - fmt.Print("bight: no config file found. Create .bight.yml? [Y/n] ") - answer, _ := reader.ReadString('\n') - answer = strings.TrimSpace(answer) - if answer != "" && answer != "y" && answer != "Y" { + if !confirm("bight: no config file found. Create .bight.yml?", true) { return nil } @@ -51,26 +45,24 @@ func promptInitConfig() error { defaultProject := filepath.Base(cwd) fmt.Printf(" Project name [%s]: ", defaultProject) - project, _ := reader.ReadString('\n') + project, _ := promptReader.ReadString('\n') project = strings.TrimSpace(project) if project == "" { project = defaultProject } fmt.Print(" Env file path [.env]: ") - envFile, _ := reader.ReadString('\n') + envFile, _ := promptReader.ReadString('\n') envFile = strings.TrimSpace(envFile) if envFile == "" { envFile = ".env" } var copySource string - fmt.Print(" Seed this file from another path on first worktree init? [y/N] ") - seedAnswer, _ := reader.ReadString('\n') - if v := strings.TrimSpace(seedAnswer); v == "y" || v == "Y" { + if confirm(" Seed this file from another path on first worktree init?", false) { defaultSource := envFile fmt.Printf(" Source path [%s]: ", defaultSource) - src, _ := reader.ReadString('\n') + src, _ := promptReader.ReadString('\n') copySource = strings.TrimSpace(src) if copySource == "" { copySource = defaultSource @@ -78,13 +70,11 @@ func promptInitConfig() error { } var vars []config.Var - fmt.Print(" Add env vars to track? [Y/n] ") - addVars, _ := reader.ReadString('\n') - if v := strings.TrimSpace(addVars); v == "" || v == "y" || v == "Y" { + if confirm(" Add env vars to track?", true) { fmt.Println(" (blank name to finish)") for { fmt.Print(" Var name: ") - name, _ := reader.ReadString('\n') + name, _ := promptReader.ReadString('\n') name = strings.TrimSpace(name) if name == "" { break @@ -93,7 +83,7 @@ func promptInitConfig() error { fmt.Println(" 1) template - interpolate branch/project name (default)") fmt.Println(" 2) random - fresh random value on each checkout") fmt.Print(" Choice [1]: ") - choice, _ := reader.ReadString('\n') + choice, _ := promptReader.ReadString('\n') strategy := "template" if strings.TrimSpace(choice) == "2" { strategy = "random" diff --git a/cmd/prompt.go b/cmd/prompt.go new file mode 100644 index 0000000..02b3704 --- /dev/null +++ b/cmd/prompt.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" +) + +// promptReader is the source confirm() and other prompts read answers +// from. Tests swap it via setPromptReader. +var promptReader = bufio.NewReader(os.Stdin) + +// promptOut is where the question is written. Tests swap it. +var promptOut io.Writer = os.Stdout + +// isPromptTTY reports whether stdin is connected to a terminal. Tests +// swap it to force the TTY / non-TTY branch independently of promptReader. +var isPromptTTY = defaultIsPromptTTY + +func defaultIsPromptTTY() bool { + stat, err := os.Stdin.Stat() + if err != nil { + return false + } + return (stat.Mode() & os.ModeCharDevice) != 0 +} + +// confirm prints question with a [Y/n] / [y/N] suffix and reads a y/n +// answer from stdin. If stdin is not a TTY, the question is echoed for +// visibility and def is returned without blocking. An empty line, EOF, +// or an unrecognized answer also yields def. +func confirm(question string, def bool) bool { + suffix := "[Y/n]" + if !def { + suffix = "[y/N]" + } + + if !isPromptTTY() { + fmt.Fprintf(promptOut, "%s %s\n", question, suffix) + return def + } + + fmt.Fprintf(promptOut, "%s %s ", question, suffix) + line, err := promptReader.ReadString('\n') + if err != nil && line == "" { + return def + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return true + case "n", "no": + return false + default: + return def + } +} diff --git a/cmd/prompt_test.go b/cmd/prompt_test.go new file mode 100644 index 0000000..71eb030 --- /dev/null +++ b/cmd/prompt_test.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "bufio" + "bytes" + "strings" + "testing" +) + +// withStubPrompt swaps the package-level prompt I/O for the duration of a +// test and restores it on cleanup. tty controls whether isPromptTTY reports +// a terminal, and input is the bytes confirm/promptReader will read. +func withStubPrompt(t *testing.T, tty bool, input string) *bytes.Buffer { + t.Helper() + origIn := promptReader + origOut := promptOut + origTTY := isPromptTTY + + var out bytes.Buffer + promptReader = bufio.NewReader(strings.NewReader(input)) + promptOut = &out + isPromptTTY = func() bool { return tty } + + t.Cleanup(func() { + promptReader = origIn + promptOut = origOut + isPromptTTY = origTTY + }) + return &out +} + +func TestConfirm_NonTTYReturnsDefault(t *testing.T) { + out := withStubPrompt(t, false, "n\n") + if !confirm("Seed?", true) { + t.Errorf("non-TTY: expected default=true to be returned") + } + if !strings.Contains(out.String(), "Seed?") { + t.Errorf("non-TTY: expected question to be echoed, got %q", out.String()) + } +} + +func TestConfirm_EmptyLineReturnsDefault(t *testing.T) { + withStubPrompt(t, true, "\n") + if !confirm("Seed?", true) { + t.Errorf("empty input with def=true should yield true") + } +} + +func TestConfirm_ParsesYesNo(t *testing.T) { + cases := []struct { + input string + def bool + want bool + }{ + {"y\n", false, true}, + {"Y\n", false, true}, + {"yes\n", false, true}, + {"YES\n", false, true}, + {"n\n", true, false}, + {"N\n", true, false}, + {"no\n", true, false}, + {" y \n", false, true}, + {"garbage\n", true, true}, // unrecognized → def + {"garbage\n", false, false}, // unrecognized → def + } + for _, c := range cases { + t.Run(strings.TrimSpace(c.input), func(t *testing.T) { + withStubPrompt(t, true, c.input) + got := confirm("?", c.def) + if got != c.want { + t.Errorf("input=%q def=%v: got %v, want %v", c.input, c.def, got, c.want) + } + }) + } +} + +func TestConfirm_EOFReturnsDefault(t *testing.T) { + withStubPrompt(t, true, "") + if !confirm("?", true) { + t.Errorf("EOF with def=true should yield true") + } +} From 34566065451828985b0536e2b64b5900e28bf8ad Mon Sep 17 00:00:00 2001 From: Andrew A Date: Sun, 7 Jun 2026 20:30:37 +0200 Subject: [PATCH 2/2] fix: skip interactive config init when non-interactive Using the shared "confirm" helper has the consequence of opting into defaults in the ineraction install, which defeats the purpose of walking the user through the options. Prior to that, the install would've been waiting for input. --- cmd/install.go | 5 +++++ cmd/install_test.go | 48 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 cmd/install_test.go diff --git a/cmd/install.go b/cmd/install.go index 6ce2e89..353d2fe 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -34,6 +34,11 @@ func promptInitConfig() error { return err } + if !isPromptTTY() { + fmt.Fprintln(os.Stderr, output.WarnStderr("bight: no config file found and stdin is not a TTY; skipping interactive setup. Create .bight.yml manually or re-run from a terminal.")) + return nil + } + if !confirm("bight: no config file found. Create .bight.yml?", true) { return nil } diff --git a/cmd/install_test.go b/cmd/install_test.go new file mode 100644 index 0000000..25e93b2 --- /dev/null +++ b/cmd/install_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "os" + "strings" + "testing" +) + +func TestPromptInitConfig_NonTTYBailsOut(t *testing.T) { + // Isolate from any real ~/.bight.yml and from the worktree's .bight.yml + // so config.Load() returns os.ErrNotExist. + t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) + + withStubPrompt(t, false, "") + + if err := promptInitConfig(); err != nil { + t.Fatalf("promptInitConfig: %v", err) + } + + if _, err := os.Stat(".bight.yml"); !os.IsNotExist(err) { + t.Errorf("expected no .bight.yml to be written under non-TTY, stat err=%v", err) + } +} + +func TestPromptInitConfig_TTYCreatesConfig(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) + + // Accept "Create .bight.yml?" (Y), accept default project, default env + // file, decline seed, decline add vars. + withStubPrompt(t, true, strings.Join([]string{ + "y", // Create .bight.yml? + "", // Project name (default) + "", // Env file path (default) + "n", // Seed from another path? + "n", // Add env vars? + "", + }, "\n")) + + if err := promptInitConfig(); err != nil { + t.Fatalf("promptInitConfig: %v", err) + } + + if _, err := os.Stat(".bight.yml"); err != nil { + t.Errorf("expected .bight.yml to be written under TTY: %v", err) + } +}