Skip to content

Commit fdce698

Browse files
skarimCopilot
andcommitted
Handle SIGINT gracefully during interactive prompts
When Ctrl+C is pressed during a prompter interaction, the CLI now prints a friendly 'Received interrupt, aborting operation' message instead of ugly wrapped errors like 'failed to read prefix: could not prompt: interrupt'. Changes: - Add isInterruptError(), printInterrupt(), and errInterrupt sentinel to cmd/utils.go for centralized interrupt detection - Update all 8 prompt sites (init, push, checkout, utils) to detect survey's terminal.InterruptErr and exit cleanly - Update callers of resolveStack, pickRemote, and ensureRerere to propagate interrupt without double-printing errors - Change ensureRerere signature to return error so callers can abort on interrupt - Add tests for interrupt detection helpers Closes td-746bdc Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0a55e35 commit fdce698

File tree

9 files changed

+172
-18
lines changed

9 files changed

+172
-18
lines changed

cmd/add.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"fmt"
55

6+
"github.com/cli/go-gh/v2/pkg/prompter"
67
"github.com/github/gh-stack/internal/branch"
78
"github.com/github/gh-stack/internal/config"
89
"github.com/github/gh-stack/internal/git"
@@ -150,10 +151,16 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
150151
branch.FollowsNumbering(s.Prefix, existingBranches[len(existingBranches)-1]) {
151152
branchName = branch.NextNumberedName(s.Prefix, existingBranches)
152153
} else {
153-
fmt.Fprintf(cfg.Err, "Enter a name for the new branch: ")
154-
if _, err := fmt.Fscan(cfg.In, &branchName); err != nil {
154+
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
155+
input, err := p.Input("Enter a name for the new branch", "")
156+
if err != nil {
157+
if isInterruptError(err) {
158+
printInterrupt(cfg)
159+
return nil
160+
}
155161
return fmt.Errorf("could not read branch name: %w", err)
156162
}
163+
branchName = input
157164
if s.Prefix != "" && branchName != "" {
158165
branchName = s.Prefix + "/" + branchName
159166
cfg.Infof("Branch name prefixed: %s", branchName)

cmd/checkout.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"errors"
45
"fmt"
56
"strconv"
67

@@ -69,7 +70,9 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
6970
// Interactive picker mode
7071
s, err = interactiveStackPicker(cfg, sf)
7172
if err != nil {
72-
cfg.Errorf("%s", err)
73+
if !errors.Is(err, errInterrupt) {
74+
cfg.Errorf("%s", err)
75+
}
7376
return nil
7477
}
7578
if s == nil {
@@ -158,6 +161,10 @@ func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Sta
158161
options,
159162
)
160163
if err != nil {
164+
if isInterruptError(err) {
165+
printInterrupt(cfg)
166+
return nil, errInterrupt
167+
}
161168
return nil, fmt.Errorf("stack selection: %w", err)
162169
}
163170

cmd/init.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"errors"
45
"fmt"
56
"strings"
67

@@ -58,7 +59,9 @@ func runInit(cfg *config.Config, opts *initOptions) error {
5859
trunk := opts.base
5960

6061
// Enable git rerere so conflict resolutions are remembered.
61-
ensureRerere(cfg)
62+
if err := ensureRerere(cfg); errors.Is(err, errInterrupt) {
63+
return nil
64+
}
6265

6366
if trunk == "" {
6467
trunk, err = git.DefaultBranch()
@@ -160,6 +163,10 @@ func runInit(cfg *config.Config, opts *initOptions) error {
160163
if opts.prefix == "" {
161164
prefixInput, err := p.Input("Set a branch prefix? (leave blank to skip)", "")
162165
if err != nil {
166+
if isInterruptError(err) {
167+
printInterrupt(cfg)
168+
return nil
169+
}
163170
cfg.Errorf("failed to read prefix: %s", err)
164171
return nil
165172
}
@@ -174,6 +181,10 @@ func runInit(cfg *config.Config, opts *initOptions) error {
174181
true,
175182
)
176183
if err != nil {
184+
if isInterruptError(err) {
185+
printInterrupt(cfg)
186+
return nil
187+
}
177188
cfg.Errorf("failed to confirm branch selection: %s", err)
178189
return nil
179190
}
@@ -193,6 +204,10 @@ func runInit(cfg *config.Config, opts *initOptions) error {
193204
}
194205
branchName, err := p.Input(prompt, "")
195206
if err != nil {
207+
if isInterruptError(err) {
208+
printInterrupt(cfg)
209+
return nil
210+
}
196211
cfg.Errorf("failed to read branch name: %s", err)
197212
return nil
198213
}

cmd/interrupt_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package cmd
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
"testing"
8+
9+
"github.com/AlecAivazis/survey/v2/terminal"
10+
"github.com/github/gh-stack/internal/config"
11+
)
12+
13+
func TestIsInterruptError_DirectMatch(t *testing.T) {
14+
if !isInterruptError(terminal.InterruptErr) {
15+
t.Error("expected true for terminal.InterruptErr")
16+
}
17+
}
18+
19+
func TestIsInterruptError_Wrapped(t *testing.T) {
20+
// This is how the prompter library wraps the interrupt error.
21+
wrapped := fmt.Errorf("could not prompt: %w", terminal.InterruptErr)
22+
if !isInterruptError(wrapped) {
23+
t.Error("expected true for wrapped interrupt error")
24+
}
25+
}
26+
27+
func TestIsInterruptError_DoubleWrapped(t *testing.T) {
28+
// Simulate additional wrapping by callers.
29+
inner := fmt.Errorf("could not prompt: %w", terminal.InterruptErr)
30+
outer := fmt.Errorf("stack selection: %w", inner)
31+
if !isInterruptError(outer) {
32+
t.Error("expected true for double-wrapped interrupt error")
33+
}
34+
}
35+
36+
func TestIsInterruptError_NonInterrupt(t *testing.T) {
37+
if isInterruptError(errors.New("some other error")) {
38+
t.Error("expected false for non-interrupt error")
39+
}
40+
}
41+
42+
func TestIsInterruptError_Nil(t *testing.T) {
43+
if isInterruptError(nil) {
44+
t.Error("expected false for nil error")
45+
}
46+
}
47+
48+
func TestPrintInterrupt_Output(t *testing.T) {
49+
cfg, outR, errR := config.NewTestConfig()
50+
printInterrupt(cfg)
51+
output := collectOutput(cfg, outR, errR)
52+
53+
if !strings.Contains(output, "Received interrupt, aborting operation") {
54+
t.Errorf("expected interrupt message, got: %s", output)
55+
}
56+
// Should NOT contain error marker (✗)
57+
if strings.Contains(output, "\u2717") {
58+
t.Errorf("interrupt message should not use error format, got: %s", output)
59+
}
60+
}
61+
62+
func TestErrInterrupt_IsDistinct(t *testing.T) {
63+
if errors.Is(errInterrupt, terminal.InterruptErr) {
64+
t.Error("errInterrupt sentinel should not match terminal.InterruptErr")
65+
}
66+
if !errors.Is(errInterrupt, errInterrupt) {
67+
t.Error("errInterrupt should match itself")
68+
}
69+
}

cmd/push.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ func runPush(cfg *config.Config, opts *pushOptions) error {
7777
// Push all active branches atomically
7878
remote, err := pickRemote(cfg, currentBranch)
7979
if err != nil {
80-
cfg.Errorf("%s", err)
80+
if !errors.Is(err, errInterrupt) {
81+
cfg.Errorf("%s", err)
82+
}
8183
return nil
8284
}
8385
merged := s.MergedBranches()
@@ -118,7 +120,13 @@ func runPush(cfg *config.Config, opts *pushOptions) error {
118120
if !opts.auto && cfg.IsInteractive() {
119121
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
120122
input, err := p.Input(fmt.Sprintf("Title for PR (branch %s):", b.Branch), title)
121-
if err == nil && input != "" {
123+
if err != nil {
124+
if isInterruptError(err) {
125+
printInterrupt(cfg)
126+
return nil
127+
}
128+
// Non-interrupt error: keep the auto-generated title.
129+
} else if input != "" {
122130
title = input
123131
}
124132
}
@@ -248,6 +256,10 @@ func pickRemote(cfg *config.Config, branch string) (string, error) {
248256
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
249257
selected, promptErr := p.Select("Multiple remotes found. Which remote should be used?", "", multi.Remotes)
250258
if promptErr != nil {
259+
if isInterruptError(promptErr) {
260+
printInterrupt(cfg)
261+
return "", errInterrupt
262+
}
251263
return "", fmt.Errorf("remote selection: %w", promptErr)
252264
}
253265
return multi.Remotes[selected], nil

cmd/rebase.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"os"
78
"path/filepath"
@@ -88,12 +89,16 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
8889
currentBranch := result.CurrentBranch
8990

9091
// Enable git rerere so conflict resolutions are remembered.
91-
ensureRerere(cfg)
92+
if err := ensureRerere(cfg); errors.Is(err, errInterrupt) {
93+
return nil
94+
}
9295

9396
// Resolve remote for fetch and trunk comparison
9497
remote, err := pickRemote(cfg, currentBranch)
9598
if err != nil {
96-
cfg.Errorf("%s", err)
99+
if !errors.Is(err, errInterrupt) {
100+
cfg.Errorf("%s", err)
101+
}
97102
return nil
98103
}
99104

cmd/sync.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"errors"
45
"fmt"
56
"strings"
67

@@ -52,13 +53,17 @@ func runSync(cfg *config.Config, _ *syncOptions) error {
5253
// Resolve remote once for fetch and push
5354
remote, err := pickRemote(cfg, currentBranch)
5455
if err != nil {
55-
cfg.Errorf("%s", err)
56+
if !errors.Is(err, errInterrupt) {
57+
cfg.Errorf("%s", err)
58+
}
5659
return nil
5760
}
5861

5962
// --- Step 1: Fetch ---
6063
// Enable git rerere so conflict resolutions are remembered.
61-
ensureRerere(cfg)
64+
if err := ensureRerere(cfg); errors.Is(err, errInterrupt) {
65+
return nil
66+
}
6267

6368
if err := git.Fetch(remote); err != nil {
6469
cfg.Warningf("Failed to fetch %s: %v", remote, err)

cmd/utils.go

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,34 @@
11
package cmd
22

33
import (
4+
"errors"
45
"fmt"
56

7+
"github.com/AlecAivazis/survey/v2/terminal"
68
"github.com/cli/go-gh/v2/pkg/prompter"
79
"github.com/github/gh-stack/internal/config"
810
"github.com/github/gh-stack/internal/git"
911
"github.com/github/gh-stack/internal/stack"
1012
)
1113

14+
// errInterrupt is a sentinel returned when a prompt is cancelled via Ctrl+C.
15+
// Callers should exit silently (the friendly message is already printed).
16+
var errInterrupt = errors.New("interrupt")
17+
18+
// isInterruptError reports whether err is (or wraps) the survey interrupt,
19+
// which is raised when the user presses Ctrl+C during a prompt.
20+
func isInterruptError(err error) bool {
21+
return errors.Is(err, terminal.InterruptErr)
22+
}
23+
24+
// printInterrupt prints a friendly message and should be called exactly once
25+
// per interrupted operation. The leading newline ensures the message starts
26+
// on its own line even if the cursor was mid-prompt.
27+
func printInterrupt(cfg *config.Config) {
28+
fmt.Fprintln(cfg.Err)
29+
cfg.Infof("Received interrupt, aborting operation")
30+
}
31+
1232
// loadStackResult holds everything returned by loadStack.
1333
type loadStackResult struct {
1434
GitDir string
@@ -46,6 +66,9 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) {
4666

4767
s, err := resolveStack(sf, branch, cfg)
4868
if err != nil {
69+
if errors.Is(err, errInterrupt) {
70+
return nil, errInterrupt
71+
}
4972
cfg.Errorf("%s", err)
5073
return nil, err
5174
}
@@ -105,6 +128,10 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac
105128
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
106129
selected, err := p.Select("Which stack would you like to use?", "", options)
107130
if err != nil {
131+
if isInterruptError(err) {
132+
printInterrupt(cfg)
133+
return nil, errInterrupt
134+
}
108135
return nil, fmt.Errorf("stack selection: %w", err)
109136
}
110137

@@ -217,30 +244,37 @@ func activeBranchNames(s *stack.Stack) []string {
217244
// user for permission before enabling it. If the user previously declined,
218245
// the prompt is suppressed. In non-interactive sessions the function is a
219246
// no-op so commands can still run in CI/scripting.
220-
func ensureRerere(cfg *config.Config) {
247+
//
248+
// Returns errInterrupt if the user pressed Ctrl+C during the prompt.
249+
func ensureRerere(cfg *config.Config) error {
221250
enabled, err := git.IsRerereEnabled()
222251
if err != nil || enabled {
223-
return
252+
return nil
224253
}
225254

226255
declined, _ := git.IsRerereDeclined()
227256
if declined {
228-
return
257+
return nil
229258
}
230259

231260
if !cfg.IsInteractive() {
232-
return
261+
return nil
233262
}
234263

235264
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
236265
ok, err := p.Confirm("Enable git rerere to remember conflict resolutions?", true)
237266
if err != nil {
238-
return
267+
if isInterruptError(err) {
268+
printInterrupt(cfg)
269+
return errInterrupt
270+
}
271+
return nil
239272
}
240273

241274
if ok {
242275
_ = git.EnableRerere()
243276
} else {
244277
_ = git.SaveRerereDeclined()
245278
}
279+
return nil
246280
}

cmd/utils_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestEnsureRerere_SkipsWhenAlreadyEnabled(t *testing.T) {
1919
defer restore()
2020

2121
cfg, outR, errR := config.NewTestConfig()
22-
ensureRerere(cfg)
22+
_ = ensureRerere(cfg)
2323
collectOutput(cfg, outR, errR)
2424

2525
if enableCalled {
@@ -40,7 +40,7 @@ func TestEnsureRerere_SkipsWhenDeclined(t *testing.T) {
4040
defer restore()
4141

4242
cfg, outR, errR := config.NewTestConfig()
43-
ensureRerere(cfg)
43+
_ = ensureRerere(cfg)
4444
collectOutput(cfg, outR, errR)
4545

4646
if enableCalled {
@@ -67,7 +67,7 @@ func TestEnsureRerere_SkipsWhenNonInteractive(t *testing.T) {
6767

6868
// NewTestConfig is non-interactive (pipes, not a TTY).
6969
cfg, outR, errR := config.NewTestConfig()
70-
ensureRerere(cfg)
70+
_ = ensureRerere(cfg)
7171
collectOutput(cfg, outR, errR)
7272

7373
if enableCalled {

0 commit comments

Comments
 (0)