diff --git a/cmd/add.go b/cmd/add.go index 2ced489..649e067 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" - "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/branch" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" @@ -146,9 +145,14 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { if s.Numbered && s.Prefix != "" { branchName = branch.NextNumberedName(s.Prefix, existingBranches) } else { - p := prompter.New(cfg.In, cfg.Out, cfg.Err) + // Pre-fill the prompt with the prefix so the user can see + // (and optionally edit) the full branch name. + prefill := "" + if s.Prefix != "" { + prefill = s.Prefix + "/" + } for { - input, err := p.Input("Enter a name for the new branch", "") + input, err := inputWithPrefill(cfg, "Enter a name for the new branch:", prefill) if err != nil { if isInterruptError(err) { printInterrupt(cfg) @@ -160,7 +164,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { cfg.Warningf("branch name cannot be empty, please try again") continue } - branchName = applyPrefix(cfg, s.Prefix, input) + branchName = input break } } diff --git a/cmd/add_test.go b/cmd/add_test.go index e6238c0..335153c 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -321,6 +321,119 @@ func TestAdd_NothingToCommit(t *testing.T) { assert.Contains(t, output, "no changes to commit") } +func TestAdd_PromptPrefillsPrefix(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Prefix: "feat", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "feat/01"}}, + }) + + var createdBranch string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "feat/01", nil }, + CreateBranchFn: func(name, base string) error { + createdBranch = name + return nil + }, + CheckoutBranchFn: func(name string) error { return nil }, + RevParseFn: func(ref string) (string, error) { return "abc", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + + var gotPrompt, gotDefault string + cfg.InputFn = func(prompt, defaultValue string) (string, error) { + gotPrompt = prompt + gotDefault = defaultValue + return "feat/my-branch", nil + } + + err := runAdd(cfg, &addOptions{}, nil) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + require.NotContains(t, output, "\u2717", "unexpected error") + assert.Contains(t, gotPrompt, ":", "prompt should end with a colon") + assert.Equal(t, "feat/", gotDefault, "prompt should pre-fill prefix/") + assert.Equal(t, "feat/my-branch", createdBranch, "full input should be used as branch name") +} + +func TestAdd_PromptNoPrefixEmptyDefault(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + var createdBranch string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CreateBranchFn: func(name, base string) error { + createdBranch = name + return nil + }, + CheckoutBranchFn: func(name string) error { return nil }, + RevParseFn: func(ref string) (string, error) { return "abc", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + + var gotDefault string + cfg.InputFn = func(prompt, defaultValue string) (string, error) { + gotDefault = defaultValue + return "my-branch", nil + } + + err := runAdd(cfg, &addOptions{}, nil) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + require.NotContains(t, output, "\u2717", "unexpected error") + assert.Equal(t, "", gotDefault, "prompt should have empty default when no prefix") + assert.Equal(t, "my-branch", createdBranch, "input should be used as-is") +} + +func TestAdd_PromptUserModifiesPrefix(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Prefix: "feat", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "feat/01"}}, + }) + + var createdBranch string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "feat/01", nil }, + CreateBranchFn: func(name, base string) error { + createdBranch = name + return nil + }, + CheckoutBranchFn: func(name string) error { return nil }, + RevParseFn: func(ref string) (string, error) { return "abc", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + + cfg.InputFn = func(prompt, defaultValue string) (string, error) { + // Simulate user changing the prefix entirely + return "custom/other-name", nil + } + + err := runAdd(cfg, &addOptions{}, nil) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + require.NotContains(t, output, "\u2717", "unexpected error") + assert.Equal(t, "custom/other-name", createdBranch, "user-modified input should be used verbatim") +} + func TestAdd_FromTrunk(t *testing.T) { gitDir := t.TempDir() saveStack(t, gitDir, stack.Stack{ diff --git a/cmd/init.go b/cmd/init.go index f89025e..83b6375 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -159,8 +159,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { } else if opts.numbered { // === NUMBERED PATH (unchanged) === if opts.prefix == "" && cfg.IsInteractive() { - p := prompter.New(cfg.In, cfg.Out, cfg.Err) - prefixInput, err := p.Input("Enter a branch prefix (required for --numbered)", "") + prefixInput, err := inputWithPrefill(cfg, "Enter a branch prefix (required for --numbered):", "") if err != nil { if isInterruptError(err) { printInterrupt(cfg) @@ -377,7 +376,7 @@ func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentB branchName = currentBranch } else { // Create a new branch — fall through to input prompt - name, err := promptBranchName(cfg, p, opts.prefix) + name, err := promptBranchName(cfg, opts.prefix) if err != nil { return nil, false, err } @@ -385,7 +384,7 @@ func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentB } } else { // On trunk or detached HEAD — prompt for name directly - name, err := promptBranchName(cfg, p, opts.prefix) + name, err := promptBranchName(cfg, opts.prefix) if err != nil { return nil, false, err } @@ -430,14 +429,16 @@ func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentB return []string{branchName}, wasAdopted, nil } -// promptBranchName prompts the user for a branch name, applying the -// explicit --prefix if set. -func promptBranchName(cfg *config.Config, p *prompter.Prompter, prefix string) (string, error) { - prompt := "What's the name of the first branch?" +// promptBranchName prompts the user for a branch name, pre-filling the +// prefix in the input when set so the user can see and edit the full name. +func promptBranchName(cfg *config.Config, prefix string) (string, error) { + prefill := "" + prompt := "What's the name of the first branch:" if prefix != "" { - prompt = fmt.Sprintf("Enter a name for the first branch (will be prefixed with %s/)", prefix) + prompt = "Enter a name for the first branch:" + prefill = prefix + "/" } - branchName, err := p.Input(prompt, "") + branchName, err := inputWithPrefill(cfg, prompt, prefill) if err != nil { if isInterruptError(err) { printInterrupt(cfg) @@ -451,9 +452,6 @@ func promptBranchName(cfg *config.Config, p *prompter.Prompter, prefix string) ( cfg.Errorf("branch name cannot be empty") return "", ErrInvalidArgs } - if prefix != "" { - branchName = prefix + "/" + branchName - } return branchName, nil } @@ -484,12 +482,12 @@ func detectPrefix(branches []string) string { func printWhatsNext(cfg *config.Config, s *stack.Stack, branches []string, hasAdopted bool, prCount int) { lastBranch := branches[len(branches)-1] - // Build the chain: main → branch1 → branch2 + // Build the chain: main ← branch1 ← branch2 parts := []string{s.Trunk.Branch} for _, b := range s.Branches { parts = append(parts, b.Branch) } - chain := strings.Join(parts, " → ") + chain := strings.Join(parts, " ← ") // Success line if hasAdopted { diff --git a/cmd/init_test.go b/cmd/init_test.go index 8b92b03..48afa85 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -581,7 +581,7 @@ func TestInit_WhatsNext_Fresh(t *testing.T) { output := collectOutput(cfg, outR, errR) assert.Contains(t, output, "Created stack") - assert.Contains(t, output, "main → my-feature") + assert.Contains(t, output, "main ← my-feature") assert.Contains(t, output, "top of stack") assert.Contains(t, output, "What's next:") assert.Contains(t, output, "gh stack add") diff --git a/cmd/submit.go b/cmd/submit.go index a57fe06..0f23479 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -281,8 +281,7 @@ func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int title, commitBody := defaultPRTitleBody(baseBranch, b.Branch) originalTitle := title if !opts.auto && cfg.IsInteractive() { - p := prompter.New(cfg.In, cfg.Out, cfg.Err) - input, err := p.Input(fmt.Sprintf("Title for PR (branch %s):", b.Branch), title) + input, err := inputWithPrefill(cfg, fmt.Sprintf("Title for PR (branch %s):", b.Branch), title) if err != nil { if isInterruptError(err) { return errInterrupt diff --git a/cmd/utils.go b/cmd/utils.go index 150498c..23939dc 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -14,6 +14,7 @@ import ( "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/stack" + "github.com/mgutz/ansi" ) // ErrSilent indicates the error has already been printed to the user. @@ -69,6 +70,47 @@ func printInterrupt(cfg *config.Config) { cfg.Infof("Received interrupt, aborting operation") } +// inputWithPrefill prompts the user for text input with the given prefill +// already editable in the input field. Unlike survey.Input's Default (which +// shows in parentheses), this places the prefill text directly in the +// editable line so the user can append to or modify it. The user's input +// is rendered in cyan for visual distinction from the prompt message. +func inputWithPrefill(cfg *config.Config, prompt, prefill string) (string, error) { + if cfg.InputFn != nil { + return cfg.InputFn(prompt, prefill) + } + + stdio := terminal.Stdio{In: cfg.In, Out: cfg.Out, Err: cfg.Err} + rr := terminal.NewRuneReader(stdio) + _ = rr.SetTermMode() + defer func() { _ = rr.RestoreTermMode() }() + + // Render the prompt in survey style: green bold "?" + bold message + icon := "?" + useColor := cfg.Terminal.IsColorEnabled() + if useColor { + icon = ansi.Color("?", "green+hb") + } + fmt.Fprintf(cfg.Out, "%s %s ", icon, prompt) + + // Set cyan color for the user's input text + if useColor { + fmt.Fprint(cfg.Out, ansi.ColorCode("cyan")) + } + + line, err := rr.ReadLineWithDefault(0, []rune(prefill)) + + // Reset color after input + if useColor { + fmt.Fprint(cfg.Out, ansi.ColorCode("reset")) + } + + if err != nil { + return "", err + } + return string(line), nil +} + // selectPromptPageSize matches the PageSize used by the go-gh prompter. const selectPromptPageSize = 20 diff --git a/internal/config/config.go b/internal/config/config.go index 8b99f9a..8f6e7e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,6 +42,10 @@ type Config struct { // ConfirmFn, when non-nil, is called instead of prompting via the // terminal. Used in tests to simulate yes/no confirmation prompts. ConfirmFn func(prompt string, defaultValue bool) (bool, error) + + // InputFn, when non-nil, is called instead of prompting via the + // terminal. Used in tests to simulate text input prompts. + InputFn func(prompt, defaultValue string) (string, error) } // New creates a new Config with terminal-aware output and color support.