From a8131584a6bf35873c79f89d5b34540eeddaf3dd Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 18 May 2026 13:10:43 -0400 Subject: [PATCH 1/3] include prefix in branch name input --- cmd/add.go | 45 ++++++++++++++-- cmd/add_test.go | 111 ++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 4 ++ 3 files changed, 156 insertions(+), 4 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index 2ced489..3b5ea52 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -3,12 +3,13 @@ package cmd import ( "fmt" - "github.com/cli/go-gh/v2/pkg/prompter" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/github/gh-stack/internal/branch" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" + "github.com/mgutz/ansi" "github.com/spf13/cobra" ) @@ -146,9 +147,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 +166,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 } } @@ -276,3 +282,34 @@ func applyPrefix(cfg *config.Config, prefix, name string) string { } return name } + +// 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. +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 := "?" + if cfg.Terminal.IsColorEnabled() { + icon = ansi.Color("?", "green+hb") + } + fmt.Fprintf(cfg.Out, "%s %s ", icon, prompt) + + line, err := rr.ReadLineWithDefault(0, []rune(prefill)) + // Move to a new line after the input + fmt.Fprintln(cfg.Out) + + if err != nil { + return "", err + } + return string(line), nil +} diff --git a/cmd/add_test.go b/cmd/add_test.go index e6238c0..6f52e36 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -321,6 +321,117 @@ 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 gotDefault string + cfg.InputFn = func(prompt, defaultValue string) (string, error) { + 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.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/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. From 516d3bd1eed75d43bd37c97360a402f2eee00422 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 18 May 2026 15:28:12 -0400 Subject: [PATCH 2/3] custom prompter with colored input text --- cmd/add.go | 35 +---------------------------------- cmd/add_test.go | 4 +++- cmd/init.go | 24 +++++++++++------------- cmd/submit.go | 3 +-- cmd/utils.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 50 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index 3b5ea52..649e067 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -3,13 +3,11 @@ package cmd import ( "fmt" - "github.com/AlecAivazis/survey/v2/terminal" "github.com/github/gh-stack/internal/branch" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" - "github.com/mgutz/ansi" "github.com/spf13/cobra" ) @@ -154,7 +152,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { prefill = s.Prefix + "/" } for { - input, err := inputWithPrefill(cfg, "Enter a name for the new branch", prefill) + input, err := inputWithPrefill(cfg, "Enter a name for the new branch:", prefill) if err != nil { if isInterruptError(err) { printInterrupt(cfg) @@ -282,34 +280,3 @@ func applyPrefix(cfg *config.Config, prefix, name string) string { } return name } - -// 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. -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 := "?" - if cfg.Terminal.IsColorEnabled() { - icon = ansi.Color("?", "green+hb") - } - fmt.Fprintf(cfg.Out, "%s %s ", icon, prompt) - - line, err := rr.ReadLineWithDefault(0, []rune(prefill)) - // Move to a new line after the input - fmt.Fprintln(cfg.Out) - - if err != nil { - return "", err - } - return string(line), nil -} diff --git a/cmd/add_test.go b/cmd/add_test.go index 6f52e36..335153c 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -344,8 +344,9 @@ func TestAdd_PromptPrefillsPrefix(t *testing.T) { cfg, outR, errR := config.NewTestConfig() - var gotDefault string + var gotPrompt, gotDefault string cfg.InputFn = func(prompt, defaultValue string) (string, error) { + gotPrompt = prompt gotDefault = defaultValue return "feat/my-branch", nil } @@ -355,6 +356,7 @@ func TestAdd_PromptPrefillsPrefix(t *testing.T) { 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") } diff --git a/cmd/init.go b/cmd/init.go index f89025e..aed904b 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 } 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 From f105d531db6df33403da35ba68a9e5974b4218e5 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 18 May 2026 17:41:19 -0400 Subject: [PATCH 3/3] minor fix: arrow direction for initialized stack --- cmd/init.go | 4 ++-- cmd/init_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index aed904b..83b6375 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -482,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")