From efc2a3f6a7dd48a83b7f5bc0d5672a92dee4195c Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 3 Feb 2026 21:14:24 -0800 Subject: [PATCH] feat(submit): add --push-only flag to skip PR creation Adds a new `--push-only` flag to `gh stack submit` that skips Phase 3 (PR creation/update) and only performs cascade + push. Useful when you want to push branches without creating or updating PRs. - Errors if combined with `--update-only` or `--web` (both affect PRs) - Flag is persisted in cascade state for conflict recovery via `continue` --- README.md | 1 + cmd/cascade.go | 5 +-- cmd/continue.go | 4 +-- cmd/submit.go | 21 ++++++++++-- cmd/sync.go | 2 +- e2e/submit_test.go | 65 ++++++++++++++++++++++++++++++++++++ internal/state/state.go | 2 ++ internal/state/state_test.go | 36 ++++++++++++++++++++ 8 files changed, 128 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index fcd1b76..38513ff 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/cascade.go b/cmd/cascade.go index 482934f..5e3bebc 100644 --- a/cmd/cascade.go +++ b/cmd/cascade.go @@ -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 { @@ -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 @@ -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, } diff --git a/cmd/continue.go b/cmd/continue.go index f99d071..b5060a3 100644 --- a/cmd/continue.go +++ b/cmd/continue.go @@ -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...") @@ -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...") diff --git a/cmd/submit.go b/cmd/submit.go index ea1e90d..c4c722d 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -35,6 +35,7 @@ var ( submitDryRunFlag bool submitCurrentOnlyFlag bool submitUpdateOnlyFlag bool + submitPushOnlyFlag bool submitYesFlag bool submitWebFlag bool ) @@ -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 @@ -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...") @@ -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 != "" { @@ -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 { @@ -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) } diff --git a/cmd/sync.go b/cmd/sync.go index a5cba6d..bd9f182 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -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 } diff --git a/e2e/submit_test.go b/e2e/submit_test.go index ed1fa97..c58612b 100644 --- a/e2e/submit_test.go +++ b/e2e/submit_test.go @@ -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) + } +} diff --git a/internal/state/state.go b/internal/state/state.go index 38ab2c3..b3d25b7 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -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"` diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 8b48c78..d60fb53 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -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") + } +}