Skip to content

Commit 8893d27

Browse files
committed
preflight check for stacked PR availability in submit (#44)
* preflight check for stacked prs before submit * close pipe read end in test to avoid FD leak * concise var reuse
1 parent 8aba980 commit 8893d27

6 files changed

Lines changed: 272 additions & 9 deletions

File tree

cmd/submit.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,32 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
7777
return ErrAPIFailure
7878
}
7979

80+
// Verify that the repository has stacked PRs enabled.
81+
stacksAvailable := s.ID != ""
82+
if !stacksAvailable {
83+
if _, err := client.ListStacks(); err != nil {
84+
cfg.Warningf("Stacked PRs are not enabled for this repository")
85+
if cfg.IsInteractive() {
86+
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
87+
proceed, promptErr := p.Confirm("Would you still like to create regular PRs?", false)
88+
if promptErr != nil {
89+
if isInterruptError(promptErr) {
90+
printInterrupt(cfg)
91+
return ErrSilent
92+
}
93+
return ErrStacksUnavailable
94+
}
95+
if !proceed {
96+
return ErrStacksUnavailable
97+
}
98+
} else {
99+
return ErrStacksUnavailable
100+
}
101+
} else {
102+
stacksAvailable = true
103+
}
104+
}
105+
80106
// Sync PR state to detect merged/queued PRs before pushing.
81107
syncStackPRs(cfg, s)
82108

@@ -194,7 +220,9 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
194220
}
195221

196222
// Create or update the stack on GitHub
197-
syncStack(cfg, client, s)
223+
if stacksAvailable {
224+
syncStack(cfg, client, s)
225+
}
198226

199227
// Update base commit hashes and sync PR state
200228
updateBaseSHAs(s)

cmd/submit_test.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"io"
66
"net/url"
7+
"os"
78
"testing"
89

910
"github.com/cli/go-gh/v2/pkg/api"
@@ -829,3 +830,229 @@ func TestSubmit_CreatesMissingPRsAndUpdatesExisting(t *testing.T) {
829830
// Stack should be created with all 3 PRs
830831
assert.Contains(t, output, "Stack created on GitHub with 3 PRs")
831832
}
833+
834+
func TestSubmit_PreflightCheck_404_BailsOut(t *testing.T) {
835+
s := stack.Stack{
836+
// No ID — this is a new stack, so the pre-flight check will run.
837+
Trunk: stack.BranchRef{Branch: "main"},
838+
Branches: []stack.BranchRef{
839+
{Branch: "b1"},
840+
{Branch: "b2"},
841+
},
842+
}
843+
844+
tmpDir := t.TempDir()
845+
writeStackFile(t, tmpDir, s)
846+
847+
pushed := false
848+
mock := newSubmitMock(tmpDir, "b1")
849+
mock.PushFn = func(string, []string, bool, bool) error {
850+
pushed = true
851+
return nil
852+
}
853+
restore := git.SetOps(mock)
854+
defer restore()
855+
856+
// Non-interactive config — should bail out immediately.
857+
cfg, _, errR := config.NewTestConfig()
858+
cfg.GitHubClientOverride = &github.MockClient{
859+
ListStacksFn: func() ([]github.RemoteStack, error) {
860+
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
861+
},
862+
}
863+
864+
cmd := SubmitCmd(cfg)
865+
cmd.SetArgs([]string{"--auto"})
866+
cmd.SetOut(io.Discard)
867+
cmd.SetErr(io.Discard)
868+
err := cmd.Execute()
869+
870+
cfg.Err.Close()
871+
errOut, _ := io.ReadAll(errR)
872+
output := string(errOut)
873+
874+
assert.ErrorIs(t, err, ErrStacksUnavailable)
875+
assert.Contains(t, output, "Stacked PRs are not enabled for this repository")
876+
assert.False(t, pushed, "should not push when stacks are unavailable")
877+
}
878+
879+
func TestSubmit_PreflightCheck_404_Interactive_UserDeclinesAborts(t *testing.T) {
880+
s := stack.Stack{
881+
Trunk: stack.BranchRef{Branch: "main"},
882+
Branches: []stack.BranchRef{
883+
{Branch: "b1"},
884+
{Branch: "b2"},
885+
},
886+
}
887+
888+
tmpDir := t.TempDir()
889+
writeStackFile(t, tmpDir, s)
890+
891+
pushed := false
892+
mock := newSubmitMock(tmpDir, "b1")
893+
mock.PushFn = func(string, []string, bool, bool) error {
894+
pushed = true
895+
return nil
896+
}
897+
restore := git.SetOps(mock)
898+
defer restore()
899+
900+
// Force interactive mode; survey will fail on the pipe,
901+
// which is treated as a decline — same as user saying "no".
902+
inR, inW, _ := os.Pipe()
903+
inW.Close()
904+
defer inR.Close()
905+
906+
cfg, _, errR := config.NewTestConfig()
907+
cfg.In = inR
908+
cfg.ForceInteractive = true
909+
cfg.GitHubClientOverride = &github.MockClient{
910+
ListStacksFn: func() ([]github.RemoteStack, error) {
911+
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
912+
},
913+
}
914+
915+
cmd := SubmitCmd(cfg)
916+
cmd.SetArgs([]string{"--auto"})
917+
cmd.SetOut(io.Discard)
918+
cmd.SetErr(io.Discard)
919+
err := cmd.Execute()
920+
921+
cfg.Err.Close()
922+
errOut, _ := io.ReadAll(errR)
923+
output := string(errOut)
924+
925+
assert.ErrorIs(t, err, ErrStacksUnavailable)
926+
assert.Contains(t, output, "Stacked PRs are not enabled for this repository")
927+
assert.False(t, pushed, "should not push when user declines")
928+
}
929+
930+
func TestSyncStack_SkippedWhenStacksUnavailable(t *testing.T) {
931+
// Verify that syncStack is not called when stacksAvailable is false.
932+
// This is the core behavior enabling unstacked PR creation.
933+
s := &stack.Stack{
934+
Trunk: stack.BranchRef{Branch: "main"},
935+
Branches: []stack.BranchRef{
936+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
937+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
938+
},
939+
}
940+
941+
createCalled := false
942+
mock := &github.MockClient{
943+
CreateStackFn: func(prNumbers []int) (int, error) {
944+
createCalled = true
945+
return 42, nil
946+
},
947+
}
948+
949+
cfg, _, errR := config.NewTestConfig()
950+
951+
// When stacksAvailable=true, syncStack should be called.
952+
syncStack(cfg, mock, s)
953+
assert.True(t, createCalled, "syncStack should call CreateStack when invoked")
954+
955+
// When stacksAvailable=false, the caller (runSubmit) skips syncStack
956+
// entirely — verified by the submit_test integration tests above.
957+
// Here we just confirm the contract: if syncStack is NOT called,
958+
// CreateStack is NOT called.
959+
createCalled = false
960+
// (not calling syncStack)
961+
assert.False(t, createCalled, "CreateStack should not be called when syncStack is skipped")
962+
963+
cfg.Err.Close()
964+
_, _ = io.ReadAll(errR)
965+
}
966+
967+
func TestSubmit_PreflightCheck_EmptyList_Proceeds(t *testing.T) {
968+
s := stack.Stack{
969+
Trunk: stack.BranchRef{Branch: "main"},
970+
Branches: []stack.BranchRef{
971+
{Branch: "b1"},
972+
{Branch: "b2"},
973+
},
974+
}
975+
976+
tmpDir := t.TempDir()
977+
writeStackFile(t, tmpDir, s)
978+
979+
pushed := false
980+
mock := newSubmitMock(tmpDir, "b1")
981+
mock.PushFn = func(string, []string, bool, bool) error {
982+
pushed = true
983+
return nil
984+
}
985+
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
986+
return []git.CommitInfo{{Subject: "commit for " + head}}, nil
987+
}
988+
restore := git.SetOps(mock)
989+
defer restore()
990+
991+
cfg, _, errR := config.NewTestConfig()
992+
cfg.GitHubClientOverride = &github.MockClient{
993+
ListStacksFn: func() ([]github.RemoteStack, error) {
994+
return []github.RemoteStack{}, nil
995+
},
996+
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
997+
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
998+
return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil
999+
},
1000+
CreateStackFn: func([]int) (int, error) { return 99, nil },
1001+
}
1002+
1003+
cmd := SubmitCmd(cfg)
1004+
cmd.SetArgs([]string{"--auto"})
1005+
cmd.SetOut(io.Discard)
1006+
cmd.SetErr(io.Discard)
1007+
err := cmd.Execute()
1008+
1009+
cfg.Err.Close()
1010+
_, _ = io.ReadAll(errR)
1011+
1012+
assert.NoError(t, err)
1013+
assert.True(t, pushed, "should proceed with push when ListStacks succeeds")
1014+
}
1015+
1016+
func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) {
1017+
s := stack.Stack{
1018+
ID: "42", // Existing stack — pre-flight check should be skipped.
1019+
Trunk: stack.BranchRef{Branch: "main"},
1020+
Branches: []stack.BranchRef{
1021+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
1022+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
1023+
},
1024+
}
1025+
1026+
tmpDir := t.TempDir()
1027+
writeStackFile(t, tmpDir, s)
1028+
1029+
listStacksCalled := false
1030+
mock := newSubmitMock(tmpDir, "b1")
1031+
mock.PushFn = func(string, []string, bool, bool) error { return nil }
1032+
restore := git.SetOps(mock)
1033+
defer restore()
1034+
1035+
cfg, _, errR := config.NewTestConfig()
1036+
cfg.GitHubClientOverride = &github.MockClient{
1037+
ListStacksFn: func() ([]github.RemoteStack, error) {
1038+
listStacksCalled = true
1039+
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
1040+
},
1041+
FindPRForBranchFn: func(string) (*github.PullRequest, error) {
1042+
return &github.PullRequest{Number: 10, URL: "https://github.com/o/r/pull/10"}, nil
1043+
},
1044+
UpdateStackFn: func(string, []int) error { return nil },
1045+
}
1046+
1047+
cmd := SubmitCmd(cfg)
1048+
cmd.SetArgs([]string{"--auto"})
1049+
cmd.SetOut(io.Discard)
1050+
cmd.SetErr(io.Discard)
1051+
err := cmd.Execute()
1052+
1053+
cfg.Err.Close()
1054+
_, _ = io.ReadAll(errR)
1055+
1056+
assert.NoError(t, err)
1057+
assert.False(t, listStacksCalled, "ListStacks should not be called when stack ID already exists")
1058+
}

cmd/utils.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ var ErrSilent = &ExitError{Code: 1}
2020

2121
// Typed exit errors for programmatic detection by scripts and agents.
2222
var (
23-
ErrNotInStack = &ExitError{Code: 2} // branch/stack not found
24-
ErrConflict = &ExitError{Code: 3} // rebase conflict
25-
ErrAPIFailure = &ExitError{Code: 4} // GitHub API error
26-
ErrInvalidArgs = &ExitError{Code: 5} // invalid arguments or flags
27-
ErrDisambiguate = &ExitError{Code: 6} // multiple stacks/remotes, can't auto-select
28-
ErrRebaseActive = &ExitError{Code: 7} // rebase already in progress
29-
ErrLockFailed = &ExitError{Code: 8} // could not acquire stack file lock
23+
ErrNotInStack = &ExitError{Code: 2} // branch/stack not found
24+
ErrConflict = &ExitError{Code: 3} // rebase conflict
25+
ErrAPIFailure = &ExitError{Code: 4} // GitHub API error
26+
ErrInvalidArgs = &ExitError{Code: 5} // invalid arguments or flags
27+
ErrDisambiguate = &ExitError{Code: 6} // multiple stacks/remotes, can't auto-select
28+
ErrRebaseActive = &ExitError{Code: 7} // rebase already in progress
29+
ErrLockFailed = &ExitError{Code: 8} // could not acquire stack file lock
30+
ErrStacksUnavailable = &ExitError{Code: 9} // stacked PRs not available for this repository
3031
)
3132

3233
// ExitError is returned by commands to indicate a specific exit code.

docs/src/content/docs/reference/cli.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,3 +441,4 @@ gh stack feedback "Support for reordering branches"
441441
| 6 | Disambiguation required (branch belongs to multiple stacks) |
442442
| 7 | Rebase already in progress |
443443
| 8 | Stack is locked by another process |
444+
| 9 | Stacked PRs not enabled for this repository |

internal/config/config.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ type Config struct {
3030
// GitHubClientOverride, when non-nil, is returned by GitHubClient()
3131
// instead of creating a real client. Used in tests to inject a MockClient.
3232
GitHubClientOverride ghapi.ClientOps
33+
34+
// ForceInteractive, when true, makes IsInteractive() return true
35+
// regardless of the terminal state. Used in tests.
36+
ForceInteractive bool
3337
}
3438

3539
// New creates a new Config with terminal-aware output and color support.
@@ -106,7 +110,7 @@ func (c *Config) PRLink(number int, url string) string {
106110
}
107111

108112
func (c *Config) IsInteractive() bool {
109-
return c.Terminal.IsTerminalOutput()
113+
return c.ForceInteractive || c.Terminal.IsTerminalOutput()
110114
}
111115

112116
func (c *Config) Repo() (repository.Repository, error) {

skills/gh-stack/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,7 @@ gh stack submit --auto --draft
539539
- Pushes all active (non-merged) branches atomically (`--force-with-lease --atomic`)
540540
- Creates a new PR for each branch that doesn't have one (base set to the first non-merged ancestor branch)
541541
- After creating PRs, links them together as a **Stack** on GitHub (requires the repository to have stacks enabled)
542+
- If stacks are not available (exit code 9), the repository does not have stacked PRs enabled. In interactive mode, `submit` offers to create regular (unstacked) PRs instead. In non-interactive mode, it exits with code 9.
542543
- Syncs PR metadata for branches that already have PRs
543544

544545
**PR title auto-generation (`--auto`):**
@@ -783,6 +784,7 @@ gh stack unstack feature-auth
783784
| 6 | Disambiguation required | A branch belongs to multiple stacks. Run `gh stack checkout <specific-branch>` to switch to a non-shared branch first |
784785
| 7 | Rebase already in progress | Run `gh stack rebase --continue` (after resolving conflicts) or `gh stack rebase --abort` to start over |
785786
| 8 | Stack is locked | Another `gh stack` process is writing the stack file. Wait and retry — the lock times out after 5 seconds |
787+
| 9 | Stacked PRs unavailable | The repository does not have stacked PRs enabled. `submit` will offer to create regular (unstacked) PRs in interactive mode |
786788

787789
## Known limitations
788790

0 commit comments

Comments
 (0)