Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ If a rebase conflict occurs, resolve it and run `gh stack continue`.
| `--dry-run` | Show what would happen without doing it |
| `--current-only` | Only submit the current branch, not descendants |
| `--update-only` | Only update existing PRs, don't create new ones |
| `--push-only` | Skip PR creation/update, only cascade and push |

### cascade

Expand Down
5 changes: 3 additions & 2 deletions cmd/cascade.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func runCascade(cmd *cobra.Command, args []string) error {
}
}

err = doCascadeWithState(g, cfg, branches, cascadeDryRunFlag, state.OperationCascade, false, false, nil, stashRef)
err = doCascadeWithState(g, cfg, branches, cascadeDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef)

// Restore auto-stashed changes after operation (unless conflict, which saves stash in state)
if stashRef != "" && err != ErrConflict {
Expand All @@ -103,7 +103,7 @@ func runCascade(cmd *cobra.Command, args []string) error {
// doCascadeWithState performs cascade and saves state with the given operation type.
// allBranches is the complete list of branches for submit operations (used for push/PR after continue).
// stashRef is the commit hash of auto-stashed changes (if any), persisted to state on conflict.
func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly, web bool, allBranches []string, stashRef string) error {
func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly, web, pushOnly bool, allBranches []string, stashRef string) error {
originalBranch, err := g.CurrentBranch()
if err != nil {
return err
Expand Down Expand Up @@ -181,6 +181,7 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
Operation: operation,
UpdateOnly: updateOnly,
Web: web,
PushOnly: pushOnly,
Branches: allBranches,
StashRef: stashRef,
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/continue.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func runContinue(cmd *cobra.Command, args []string) error {
// Remove state file before continuing (will be recreated if conflict)
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup

if cascadeErr := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Web, st.Branches, st.StashRef); cascadeErr != nil {
if cascadeErr := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Web, st.PushOnly, st.Branches, st.StashRef); cascadeErr != nil {
// Stash handling is done by doCascadeWithState (conflict saves in state, errors restore)
if cascadeErr != ErrConflict && st.StashRef != "" {
fmt.Println("Restoring auto-stashed changes...")
Expand Down Expand Up @@ -111,7 +111,7 @@ func runContinue(cmd *cobra.Command, args []string) error {
allBranches = append(allBranches, node)
}

err = doSubmitPushAndPR(g, cfg, root, allBranches, false, st.UpdateOnly, st.Web)
err = doSubmitPushAndPR(g, cfg, root, allBranches, false, st.UpdateOnly, st.Web, st.PushOnly)
// Restore stash after submit completes
if st.StashRef != "" {
fmt.Println("Restoring auto-stashed changes...")
Expand Down
21 changes: 18 additions & 3 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ var (
submitDryRunFlag bool
submitCurrentOnlyFlag bool
submitUpdateOnlyFlag bool
submitPushOnlyFlag bool
submitYesFlag bool
submitWebFlag bool
)
Expand All @@ -43,12 +44,21 @@ func init() {
submitCmd.Flags().BoolVar(&submitDryRunFlag, "dry-run", false, "show what would be done without doing it")
submitCmd.Flags().BoolVar(&submitCurrentOnlyFlag, "current-only", false, "only submit current branch, not descendants")
submitCmd.Flags().BoolVar(&submitUpdateOnlyFlag, "update-only", false, "only update existing PRs, don't create new ones")
submitCmd.Flags().BoolVar(&submitPushOnlyFlag, "push-only", false, "skip PR creation/update, only cascade and push")
submitCmd.Flags().BoolVarP(&submitYesFlag, "yes", "y", false, "skip interactive prompts and use auto-generated title/description for PRs")
submitCmd.Flags().BoolVarP(&submitWebFlag, "web", "w", false, "open created/updated PRs in web browser")
rootCmd.AddCommand(submitCmd)
}

func runSubmit(cmd *cobra.Command, args []string) error {
// Validate flag combinations
if submitPushOnlyFlag && submitUpdateOnlyFlag {
return fmt.Errorf("--push-only and --update-only cannot be used together: --push-only skips all PR operations")
}
if submitPushOnlyFlag && submitWebFlag {
return fmt.Errorf("--push-only and --web cannot be used together: --push-only skips all PR operations")
}

cwd, err := os.Getwd()
if err != nil {
return err
Expand Down Expand Up @@ -124,7 +134,7 @@ func runSubmit(cmd *cobra.Command, args []string) error {

// Phase 1: Cascade
fmt.Println("=== Phase 1: Cascade ===")
if cascadeErr := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, branchNames, stashRef); cascadeErr != nil {
if cascadeErr := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, submitPushOnlyFlag, branchNames, stashRef); cascadeErr != nil {
// Stash is saved in state for conflicts; restore on other errors
if cascadeErr != ErrConflict && stashRef != "" {
fmt.Println("Restoring auto-stashed changes...")
Expand All @@ -136,7 +146,7 @@ func runSubmit(cmd *cobra.Command, args []string) error {
}

// Phases 2 & 3
err = doSubmitPushAndPR(g, cfg, root, branches, submitDryRunFlag, submitUpdateOnlyFlag, submitWebFlag)
err = doSubmitPushAndPR(g, cfg, root, branches, submitDryRunFlag, submitUpdateOnlyFlag, submitWebFlag, submitPushOnlyFlag)

// Restore auto-stashed changes after operation completes
if stashRef != "" {
Expand All @@ -151,7 +161,7 @@ func runSubmit(cmd *cobra.Command, args []string) error {

// doSubmitPushAndPR handles push and PR creation/update phases.
// This is called after cascade succeeds (or from continue after conflict resolution).
func doSubmitPushAndPR(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tree.Node, dryRun, updateOnly, openWeb bool) error {
func doSubmitPushAndPR(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tree.Node, dryRun, updateOnly, openWeb, pushOnly bool) error {
// Phase 2: Push all branches
fmt.Println("\n=== Phase 2: Push ===")
for _, b := range branches {
Expand All @@ -168,6 +178,11 @@ func doSubmitPushAndPR(g *git.Git, cfg *config.Config, root *tree.Node, branches
}

// Phase 3: Create/update PRs
if pushOnly {
fmt.Println("\n=== Phase 3: PRs ===")
fmt.Println("Skipped (--push-only)")
return nil
}
return doSubmitPRs(g, cfg, root, branches, dryRun, updateOnly, openWeb)
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ func runSync(cmd *cobra.Command, args []string) error {
for _, child := range root.Children {
allBranches := []*tree.Node{child}
allBranches = append(allBranches, tree.GetDescendants(child)...)
if err := doCascadeWithState(g, cfg, allBranches, syncDryRunFlag, state.OperationCascade, false, false, nil, stashRef); err != nil {
if err := doCascadeWithState(g, cfg, allBranches, syncDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef); err != nil {
if err == ErrConflict {
hitConflict = true
}
Expand Down
65 changes: 65 additions & 0 deletions e2e/submit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,68 @@ func TestSubmitFromTrunkNoDescendantsFails(t *testing.T) {
t.Errorf("expected error about no stack branches, got: %s", result.Stderr)
}
}

func TestSubmitPushOnlyDryRun(t *testing.T) {
env := NewTestEnvWithRemote(t)
env.MustRun("init")

env.MustRun("create", "feature-1")
env.CreateCommit("feature 1 work")

result := env.MustRun("submit", "--dry-run", "--push-only")

// Should show all three phases
if !strings.Contains(result.Stdout, "Phase 1: Cascade") {
t.Error("expected cascade phase output")
}
if !strings.Contains(result.Stdout, "Phase 2: Push") {
t.Error("expected push phase output")
}
if !strings.Contains(result.Stdout, "Phase 3: PRs") {
t.Error("expected PR phase header")
}

// Should show skipped message for PR phase
if !strings.Contains(result.Stdout, "Skipped (--push-only)") {
t.Error("expected 'Skipped (--push-only)' message")
}

// Should NOT mention PR creation
if strings.Contains(result.Stdout, "Would create PR") {
t.Error("should not mention PR creation with --push-only")
}
}

func TestSubmitPushOnlyWithUpdateOnlyFails(t *testing.T) {
env := NewTestEnvWithRemote(t)
env.MustRun("init")

env.MustRun("create", "feature-1")
env.CreateCommit("feature 1 work")

result := env.Run("submit", "--dry-run", "--push-only", "--update-only")

if result.Success() {
t.Error("expected --push-only and --update-only to fail")
}
if !strings.Contains(result.Stderr, "--push-only and --update-only cannot be used together") {
t.Errorf("expected error about conflicting flags, got: %s", result.Stderr)
}
}

func TestSubmitPushOnlyWithWebFails(t *testing.T) {
env := NewTestEnvWithRemote(t)
env.MustRun("init")

env.MustRun("create", "feature-1")
env.CreateCommit("feature 1 work")

result := env.Run("submit", "--dry-run", "--push-only", "--web")

if result.Success() {
t.Error("expected --push-only and --web to fail")
}
if !strings.Contains(result.Stderr, "--push-only and --web cannot be used together") {
t.Errorf("expected error about conflicting flags, got: %s", result.Stderr)
}
}
2 changes: 2 additions & 0 deletions internal/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ type CascadeState struct {
UpdateOnly bool `json:"update_only,omitempty"`
// Web (submit only) - if true, open PRs in browser after creation/update
Web bool `json:"web,omitempty"`
// PushOnly (submit only) - if true, skip PR creation/update phase entirely
PushOnly bool `json:"push_only,omitempty"`
// Branches (submit only) - the complete list of branches being submitted.
// Used to rebuild the full set for push/PR phases after cascade completes.
Branches []string `json:"branches,omitempty"`
Expand Down
36 changes: 36 additions & 0 deletions internal/state/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,39 @@ func TestSubmitState(t *testing.T) {
t.Errorf("expected first branch %q, got %q", "feature-a", loaded.Branches[0])
}
}

func TestSubmitStatePushOnly(t *testing.T) {
dir := t.TempDir()
gitDir := filepath.Join(dir, ".git")
if err := os.MkdirAll(gitDir, 0755); err != nil {
t.Fatal(err)
}

s := &state.CascadeState{
Current: "feature-b",
Pending: []string{"feature-c"},
OriginalHead: "abc123",
Operation: state.OperationSubmit,
PushOnly: true,
Branches: []string{"feature-a", "feature-b", "feature-c"},
}

if err := state.Save(gitDir, s); err != nil {
t.Fatalf("Save failed: %v", err)
}

loaded, err := state.Load(gitDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}

if loaded.Operation != state.OperationSubmit {
t.Errorf("expected operation %q, got %q", state.OperationSubmit, loaded.Operation)
}
if !loaded.PushOnly {
t.Error("expected PushOnly to be true")
}
if loaded.UpdateOnly {
t.Error("expected UpdateOnly to be false")
}
}
Loading