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
2 changes: 1 addition & 1 deletion cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package cmd
import (
"fmt"

"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/prompter"
"github.com/github/gh-stack/internal/branch"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
Expand Down
4 changes: 1 addition & 3 deletions cmd/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"strings"

"github.com/cli/go-gh/v2/pkg/api"
"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/prompter"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/github"
Expand Down Expand Up @@ -376,7 +376,6 @@ func handleCompositionConflict(
selected, err := p.Select("How would you like to resolve this?", "", options)
if err != nil {
if isInterruptError(err) {
clearSelectPrompt(cfg, len(options))
printInterrupt(cfg)
return nil, errInterrupt
}
Expand Down Expand Up @@ -574,7 +573,6 @@ func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Sta
)
if err != nil {
if isInterruptError(err) {
clearSelectPrompt(cfg, len(options))
printInterrupt(cfg)
return nil, errInterrupt
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"fmt"
"strings"

"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/prompter"
"github.com/github/gh-stack/internal/branch"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
Expand Down
2 changes: 1 addition & 1 deletion cmd/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"fmt"

"github.com/cli/go-gh/v2/pkg/browser"
"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/prompter"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/stack"
"github.com/spf13/cobra"
Expand Down
3 changes: 1 addition & 2 deletions cmd/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"errors"
"fmt"

"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/prompter"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/modify"
Expand Down Expand Up @@ -151,7 +151,6 @@ func pickRemote(cfg *config.Config, branch, remoteOverride string) (string, erro
selected, promptErr := p.Select("Multiple remotes found. Which remote should be used?", "", multi.Remotes)
if promptErr != nil {
if isInterruptError(promptErr) {
clearSelectPrompt(cfg, len(multi.Remotes))
printInterrupt(cfg)
return "", errInterrupt
}
Expand Down
3 changes: 3 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ func Execute() {
wrapCmd.SetArgs(append([]string{"stack"}, os.Args[1:]...))

if err := wrapCmd.Execute(); err != nil {
if errors.Is(err, errInterrupt) {
os.Exit(1)
}
var exitErr *ExitError
if errors.As(err, &exitErr) {
os.Exit(exitErr.Code)
Expand Down
10 changes: 7 additions & 3 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"strings"

"github.com/cli/go-gh/v2/pkg/api"
"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/prompter"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/github"
Expand Down Expand Up @@ -84,8 +84,12 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
if _, err := client.ListStacks(); err != nil {
cfg.Warningf("Stacked PRs are not enabled for this repository")
if cfg.IsInteractive() {
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
proceed, promptErr := p.Confirm("Would you still like to create regular PRs?", false)
confirmFn := cfg.ConfirmFn
if confirmFn == nil {
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
confirmFn = p.Confirm
}
proceed, promptErr := confirmFn("Would you still like to create regular PRs?", false)
if promptErr != nil {
if isInterruptError(promptErr) {
printInterrupt(cfg)
Expand Down
11 changes: 3 additions & 8 deletions cmd/submit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"io"
"net/url"
"os"
"testing"

"github.com/cli/go-gh/v2/pkg/api"
Expand Down Expand Up @@ -905,15 +904,11 @@ func TestSubmit_PreflightCheck_404_Interactive_UserDeclinesAborts(t *testing.T)
restore := git.SetOps(mock)
defer restore()

// Force interactive mode; survey will fail on the pipe,
// which is treated as a decline — same as user saying "no".
inR, inW, _ := os.Pipe()
inW.Close()
defer inR.Close()

cfg, _, errR := config.NewTestConfig()
cfg.In = inR
cfg.ForceInteractive = true
cfg.ConfirmFn = func(prompt string, defaultValue bool) (bool, error) {
return false, nil // user declines
}
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
Expand Down
3 changes: 1 addition & 2 deletions cmd/switch.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package cmd
import (
"fmt"

"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/prompter"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -68,7 +68,6 @@ func runSwitch(cfg *config.Config) error {
selected, err := selectFn("Select a branch in the stack to switch to:", defaultOpt, options)
if err != nil {
if isInterruptError(err) {
clearSelectPrompt(cfg, len(options))
printInterrupt(cfg)
return errInterrupt
}
Expand Down
28 changes: 4 additions & 24 deletions cmd/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import (
"strconv"
"strings"

"github.com/AlecAivazis/survey/v2/terminal"
"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/prompter"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/github"
Expand Down Expand Up @@ -54,36 +53,18 @@ func (e *ExitError) Is(target error) bool {
// Callers should exit silently (the friendly message is already printed).
var errInterrupt = errors.New("interrupt")

// isInterruptError reports whether err is (or wraps) the survey interrupt,
// isInterruptError reports whether err is (or wraps) the prompt interrupt,
// which is raised when the user presses Ctrl+C during a prompt.
func isInterruptError(err error) bool {
return errors.Is(err, terminal.InterruptErr)
return errors.Is(err, prompter.ErrInterrupt)
}

// printInterrupt prints a friendly message and should be called exactly once
// per interrupted operation. The leading newline ensures the message starts
// on its own line even if the cursor was mid-prompt.
// per interrupted operation.
func printInterrupt(cfg *config.Config) {
fmt.Fprintln(cfg.Err)
cfg.Infof("Received interrupt, aborting operation")
}

// selectPromptPageSize matches the PageSize used by the go-gh prompter.
const selectPromptPageSize = 20

// clearSelectPrompt erases the rendered Select prompt from the terminal.
// survey/v2 does not call Cleanup on interrupt, leaving the question and
// option lines visible. This function moves the cursor up past those lines
// and clears to the end of the screen.
func clearSelectPrompt(cfg *config.Config, numOptions int) {
visible := numOptions
if visible > selectPromptPageSize {
visible = selectPromptPageSize
}
// 1 line for the question/filter + visible option lines
lines := 1 + visible
fmt.Fprintf(cfg.Out, "\033[%dA\033[J", lines)
}

// loadStackResult holds everything returned by loadStack.
type loadStackResult struct {
Expand Down Expand Up @@ -206,7 +187,6 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac
selected, err := p.Select("Which stack would you like to use?", "", options)
if err != nil {
if isInterruptError(err) {
clearSelectPrompt(cfg, len(options))
printInterrupt(cfg)
return nil, errInterrupt
}
Expand Down
16 changes: 9 additions & 7 deletions cmd/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,32 @@ import (
"strings"
"testing"

"github.com/AlecAivazis/survey/v2/terminal"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/github"
"github.com/github/gh-stack/internal/prompter"
"github.com/github/gh-stack/internal/stack"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestIsInterruptError_DirectMatch(t *testing.T) {
if !isInterruptError(terminal.InterruptErr) {
t.Error("expected true for terminal.InterruptErr")
if !isInterruptError(prompter.ErrInterrupt) {
t.Error("expected true for prompter.ErrInterrupt")
}
}

func TestIsInterruptError_Wrapped(t *testing.T) {
// This is how the prompter library wraps the interrupt error.
wrapped := fmt.Errorf("could not prompt: %w", terminal.InterruptErr)
wrapped := fmt.Errorf("could not prompt: %w", prompter.ErrInterrupt)
if !isInterruptError(wrapped) {
t.Error("expected true for wrapped interrupt error")
}
}

func TestIsInterruptError_DoubleWrapped(t *testing.T) {
// Simulate additional wrapping by callers.
inner := fmt.Errorf("could not prompt: %w", terminal.InterruptErr)
inner := fmt.Errorf("could not prompt: %w", prompter.ErrInterrupt)
outer := fmt.Errorf("stack selection: %w", inner)
if !isInterruptError(outer) {
t.Error("expected true for double-wrapped interrupt error")
Expand Down Expand Up @@ -65,8 +65,10 @@ func TestPrintInterrupt_Output(t *testing.T) {
}

func TestErrInterrupt_IsDistinct(t *testing.T) {
if errors.Is(errInterrupt, terminal.InterruptErr) {
t.Error("errInterrupt sentinel should not match terminal.InterruptErr")
// errInterrupt (the cmd-level sentinel) and prompter.ErrInterrupt
// are distinct errors — they should not match each other.
if errors.Is(errInterrupt, prompter.ErrInterrupt) {
t.Error("errInterrupt sentinel should not match prompter.ErrInterrupt")
}
if !errors.Is(errInterrupt, errInterrupt) {
t.Error("errInterrupt should match itself")
Expand Down
32 changes: 22 additions & 10 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
module github.com/github/gh-stack

go 1.25.7
go 1.25.8

require (
github.com/AlecAivazis/survey/v2 v2.3.7
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.1
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
Expand All @@ -13,40 +14,51 @@ require (
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/sys v0.39.0
golang.org/x/sys v0.42.0
golang.org/x/text v0.32.0
)

require (
charm.land/bubbles/v2 v2.0.0 // indirect
charm.land/bubbletea/v2 v2.0.2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
github.com/charmbracelet/x/ansi v0.10.2 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20250630141444-821143405392 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/cli/safeexec v1.0.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/henvic/httpretty v0.1.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.17 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/thlib/go-timezone-local v0.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/term v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading
Loading