Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
}
Expand Down
113 changes: 113 additions & 0 deletions cmd/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
28 changes: 13 additions & 15 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -377,15 +376,15 @@ 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
}
branchName = name
}
} 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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 1 addition & 2 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions cmd/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading