Skip to content
Open
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
30 changes: 29 additions & 1 deletion cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,32 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
return ErrAPIFailure
}

// Verify that the repository has stacked PRs enabled.
stacksAvailable := s.ID != ""
if s.ID == "" {
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)
if promptErr != nil {
if isInterruptError(promptErr) {
printInterrupt(cfg)
return ErrSilent
}
return ErrStacksUnavailable
}
if !proceed {
return ErrStacksUnavailable
}
} else {
return ErrStacksUnavailable
Comment thread
skarim marked this conversation as resolved.
}
} else {
stacksAvailable = true
}
}

// Sync PR state to detect merged/queued PRs before pushing.
syncStackPRs(cfg, s)

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

// Create or update the stack on GitHub
syncStack(cfg, client, s)
if stacksAvailable {
syncStack(cfg, client, s)
}

// Update base commit hashes and sync PR state
updateBaseSHAs(s)
Expand Down
227 changes: 227 additions & 0 deletions cmd/submit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"io"
"net/url"
"os"
"testing"

"github.com/cli/go-gh/v2/pkg/api"
Expand Down Expand Up @@ -829,3 +830,229 @@ func TestSubmit_CreatesMissingPRsAndUpdatesExisting(t *testing.T) {
// Stack should be created with all 3 PRs
assert.Contains(t, output, "Stack created on GitHub with 3 PRs")
}

func TestSubmit_PreflightCheck_404_BailsOut(t *testing.T) {
s := stack.Stack{
// No ID — this is a new stack, so the pre-flight check will run.
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

pushed := false
mock := newSubmitMock(tmpDir, "b1")
mock.PushFn = func(string, []string, bool, bool) error {
pushed = true
return nil
}
restore := git.SetOps(mock)
defer restore()

// Non-interactive config — should bail out immediately.
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
},
}

cmd := SubmitCmd(cfg)
cmd.SetArgs([]string{"--auto"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)

assert.ErrorIs(t, err, ErrStacksUnavailable)
assert.Contains(t, output, "Stacked PRs are not enabled for this repository")
assert.False(t, pushed, "should not push when stacks are unavailable")
}

func TestSubmit_PreflightCheck_404_Interactive_UserDeclinesAborts(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

pushed := false
mock := newSubmitMock(tmpDir, "b1")
mock.PushFn = func(string, []string, bool, bool) error {
pushed = true
return nil
}
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
Comment on lines +902 to +908
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestSubmit_PreflightCheck_404_Interactive_UserDeclinesAborts creates an os.Pipe() and assigns cfg.In = inR, but the read end is never closed. Please defer inR.Close() (and handle the pipe creation error) to avoid leaking file descriptors across the test suite.

This issue also appears on line 929 of the same file.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

@skarim skarim Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed with defer inR.Close() in 9c58a54.

cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
},
}

cmd := SubmitCmd(cfg)
cmd.SetArgs([]string{"--auto"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)

assert.ErrorIs(t, err, ErrStacksUnavailable)
assert.Contains(t, output, "Stacked PRs are not enabled for this repository")
assert.False(t, pushed, "should not push when user declines")
}

func TestSyncStack_SkippedWhenStacksUnavailable(t *testing.T) {
// Verify that syncStack is not called when stacksAvailable is false.
// This is the core behavior enabling unstacked PR creation.
s := &stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
},
}

createCalled := false
mock := &github.MockClient{
CreateStackFn: func(prNumbers []int) (int, error) {
createCalled = true
return 42, nil
},
}

cfg, _, errR := config.NewTestConfig()

// When stacksAvailable=true, syncStack should be called.
syncStack(cfg, mock, s)
assert.True(t, createCalled, "syncStack should call CreateStack when invoked")

// When stacksAvailable=false, the caller (runSubmit) skips syncStack
// entirely — verified by the submit_test integration tests above.
// Here we just confirm the contract: if syncStack is NOT called,
// CreateStack is NOT called.
createCalled = false
// (not calling syncStack)
assert.False(t, createCalled, "CreateStack should not be called when syncStack is skipped")

cfg.Err.Close()
_, _ = io.ReadAll(errR)
}

func TestSubmit_PreflightCheck_EmptyList_Proceeds(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

pushed := false
mock := newSubmitMock(tmpDir, "b1")
mock.PushFn = func(string, []string, bool, bool) error {
pushed = true
return nil
}
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
return []git.CommitInfo{{Subject: "commit for " + head}}, nil
}
restore := git.SetOps(mock)
defer restore()

cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil
},
CreateStackFn: func([]int) (int, error) { return 99, nil },
}

cmd := SubmitCmd(cfg)
cmd.SetArgs([]string{"--auto"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Err.Close()
_, _ = io.ReadAll(errR)

assert.NoError(t, err)
assert.True(t, pushed, "should proceed with push when ListStacks succeeds")
}

func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) {
s := stack.Stack{
ID: "42", // Existing stack — pre-flight check should be skipped.
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

listStacksCalled := false
mock := newSubmitMock(tmpDir, "b1")
mock.PushFn = func(string, []string, bool, bool) error { return nil }
restore := git.SetOps(mock)
defer restore()

cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
listStacksCalled = true
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
},
FindPRForBranchFn: func(string) (*github.PullRequest, error) {
return &github.PullRequest{Number: 10, URL: "https://github.com/o/r/pull/10"}, nil
},
UpdateStackFn: func(string, []int) error { return nil },
}

cmd := SubmitCmd(cfg)
cmd.SetArgs([]string{"--auto"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Err.Close()
_, _ = io.ReadAll(errR)

assert.NoError(t, err)
assert.False(t, listStacksCalled, "ListStacks should not be called when stack ID already exists")
}
15 changes: 8 additions & 7 deletions cmd/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ var ErrSilent = &ExitError{Code: 1}

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

// ExitError is returned by commands to indicate a specific exit code.
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -441,3 +441,4 @@ gh stack feedback "Support for reordering branches"
| 6 | Disambiguation required (branch belongs to multiple stacks) |
| 7 | Rebase already in progress |
| 8 | Stack is locked by another process |
| 9 | Stacked PRs not enabled for this repository |
Comment thread
skarim marked this conversation as resolved.
6 changes: 5 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ type Config struct {
// GitHubClientOverride, when non-nil, is returned by GitHubClient()
// instead of creating a real client. Used in tests to inject a MockClient.
GitHubClientOverride ghapi.ClientOps

// ForceInteractive, when true, makes IsInteractive() return true
// regardless of the terminal state. Used in tests.
ForceInteractive bool
}

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

func (c *Config) IsInteractive() bool {
return c.Terminal.IsTerminalOutput()
return c.ForceInteractive || c.Terminal.IsTerminalOutput()
}

func (c *Config) Repo() (repository.Repository, error) {
Expand Down
2 changes: 2 additions & 0 deletions skills/gh-stack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ gh stack submit --auto --draft
- Pushes all active (non-merged) branches atomically (`--force-with-lease --atomic`)
- Creates a new PR for each branch that doesn't have one (base set to the first non-merged ancestor branch)
- After creating PRs, links them together as a **Stack** on GitHub (requires the repository to have stacks enabled)
- 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.
Comment thread
skarim marked this conversation as resolved.
- Syncs PR metadata for branches that already have PRs

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

## Known limitations

Expand Down