diff --git a/README.md b/README.md index 7b135b0..a356c1c 100644 --- a/README.md +++ b/README.md @@ -68,38 +68,66 @@ main └── feature-auth-tests ``` -### Create PRs for Your Stack +### Rebase After Parent Changes ```bash -gh stack pr +gh stack cascade ``` -Creates a PR targeting the parent branch. If a PR already exists, updates its base. +Rebases the current branch onto its parent, then cascades to all descendants. If conflicts occur: + +```bash +# Resolve conflicts, then: +gh stack continue + +# Or abort: +gh stack abort +``` -### Push Your Stack +### Submit Your Stack ```bash -gh stack push +gh stack submit ``` -Force-pushes (with lease) all branches from trunk to your current branch, updating PR bases as needed. +The all-in-one command for getting your work onto GitHub. Submit: -### Rebase After Parent Changes +1. **Cascades** the current branch and its descendants onto their parents +2. **Pushes** all affected branches with `--force-with-lease` +3. **Creates/updates PRs** for each branch (creates as draft if mid-stack) + +This is typically what you run after making changes: ```bash -gh stack cascade +# Make some changes +git add . && git commit -m "fix: address review feedback" + +# Ship it +gh stack submit ``` -Rebases the current branch onto its parent, then cascades to all descendants. If conflicts occur: +#### Flags + +| Flag | Description | +| ---------------- | ------------------------------------------------ | +| `--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 | + +#### Conflict Resolution + +If a rebase conflict occurs during submit: ```bash -# Resolve conflicts, then: +# Resolve the conflicts, then: gh stack continue # Or abort: gh stack abort ``` +After continuing, submit resumes with push and PR phases. + ### Sync Everything ```bash @@ -119,11 +147,10 @@ Fetches from origin, fast-forwards trunk, detects merged PRs, cleans up merged b | `orphan` | Stop tracking a branch | | `link` | Associate PR number with branch | | `unlink` | Remove PR association | -| `pr` | Create or update PR targeting parent | -| `push` | Force-push stack with `--force-with-lease` | +| `submit` | Cascade, push, and create/update PRs in one command | | `cascade` | Rebase branch and descendants onto parents | -| `continue` | Resume cascade after conflict resolution | -| `abort` | Cancel cascade operation | +| `continue` | Resume operation after conflict resolution | +| `abort` | Cancel in-progress operation | | `sync` | Full sync: fetch, cleanup merged PRs, cascade all | ## How It Works diff --git a/cmd/cascade.go b/cmd/cascade.go index 0f51a7d..eae4aa3 100644 --- a/cmd/cascade.go +++ b/cmd/cascade.go @@ -88,6 +88,12 @@ func runCascade(cmd *cobra.Command, args []string) error { } func doCascade(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool) error { + return doCascadeWithState(g, cfg, branches, dryRun, state.OperationCascade, false, nil) +} + +// 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). +func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly bool, allBranches []string) error { originalBranch, err := g.CurrentBranch() if err != nil { return err @@ -162,6 +168,9 @@ func doCascade(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun boo Current: b.Name, Pending: remaining, OriginalHead: originalHead, + Operation: operation, + UpdateOnly: updateOnly, + Branches: allBranches, } _ = state.Save(g.GetGitDir(), st) //nolint:errcheck // best effort - user can recover manually diff --git a/cmd/continue.go b/cmd/continue.go index ff19095..d7e27ef 100644 --- a/cmd/continue.go +++ b/cmd/continue.go @@ -14,8 +14,8 @@ import ( var continueCmd = &cobra.Command{ Use: "continue", - Short: "Continue a cascade after resolving conflicts", - Long: `Continue a cascade operation after resolving rebase conflicts.`, + Short: "Continue an operation after resolving conflicts", + Long: `Continue a cascade or submit operation after resolving rebase conflicts.`, RunE: runContinue, } @@ -31,10 +31,10 @@ func runContinue(cmd *cobra.Command, args []string) error { g := git.New(cwd) - // Check if cascade in progress + // Check if operation in progress st, err := state.Load(g.GetGitDir()) if err != nil { - return fmt.Errorf("no cascade in progress") + return fmt.Errorf("no operation in progress") } // Complete the in-progress rebase @@ -47,13 +47,6 @@ func runContinue(cmd *cobra.Command, args []string) error { fmt.Printf("Completed %s\n", st.Current) - // Continue with remaining branches - if len(st.Pending) == 0 { - _ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup - fmt.Println("Cascade complete!") - return nil - } - cfg, err := config.Load(cwd) if err != nil { return err @@ -65,15 +58,55 @@ func runContinue(cmd *cobra.Command, args []string) error { return err } - var branches []*tree.Node - for _, name := range st.Pending { - if node := tree.FindNode(root, name); node != nil { - branches = append(branches, node) + // If there are more branches to cascade, continue cascading + if len(st.Pending) > 0 { + var branches []*tree.Node + for _, name := range st.Pending { + if node := tree.FindNode(root, name); node != nil { + branches = append(branches, node) + } + } + + // Remove state file before continuing (will be recreated if conflict) + _ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup + + if err := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Branches); err != nil { + return err // Another conflict - state saved } + } else { + // No more branches to cascade - cleanup state + _ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup } - // Remove state file before continuing (will be recreated if conflict) - _ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup + // If this was a submit operation, continue with push + PR phases + if st.Operation == state.OperationSubmit { + // Rebuild branches list from the original set of submit branches if available. + // Fall back to the current + pending branches for backward compatibility. + var branchNames []string + if len(st.Branches) > 0 { + branchNames = st.Branches + } else { + branchNames = append(branchNames, st.Current) + branchNames = append(branchNames, st.Pending...) + } + + var allBranches []*tree.Node + for _, name := range branchNames { + node := tree.FindNode(root, name) + if node == nil { + // Preserve existing behaviour: fail fast if a branch from state + // cannot be found in the current tree. + if name == st.Current { + return fmt.Errorf("branch %q not found in tree", st.Current) + } + continue + } + allBranches = append(allBranches, node) + } + + return doSubmitPushAndPR(g, cfg, root, allBranches, false, st.UpdateOnly) + } - return doCascade(g, cfg, branches, false) + fmt.Println("Cascade complete!") + return nil } diff --git a/cmd/pr.go b/cmd/pr.go deleted file mode 100644 index 7ce4a9c..0000000 --- a/cmd/pr.go +++ /dev/null @@ -1,230 +0,0 @@ -// cmd/pr.go -package cmd - -import ( - "context" - "fmt" - "os" - "strings" - - gh "github.com/cli/go-gh/v2" - "github.com/spf13/cobra" - - "github.com/boneskull/gh-stack/internal/config" - "github.com/boneskull/gh-stack/internal/git" - "github.com/boneskull/gh-stack/internal/github" - "github.com/boneskull/gh-stack/internal/tree" -) - -var prCmd = &cobra.Command{ - Use: "pr [-- ...]", - Short: "Create or update a PR for the current branch", - Long: `Create a new PR targeting the parent branch, or update an existing PR's base. - -This command wraps 'gh pr create', automatically setting the base branch to the -stack parent. Any additional flags after '--' are passed through to 'gh pr create'. - -Examples: - gh stack pr # Interactive PR creation - gh stack pr -- --title "My PR" # With title - gh stack pr -- --fill --web # Fill from commits, open in browser - gh stack pr --base main # Override base branch`, - RunE: runPR, - DisableFlagParsing: false, -} - -var prBaseFlag string - -func init() { - prCmd.Flags().StringVar(&prBaseFlag, "base", "", "override base branch (default: stack parent)") - rootCmd.AddCommand(prCmd) -} - -func runPR(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return err - } - - cfg, err := config.Load(cwd) - if err != nil { - return err - } - - ghClient, err := github.NewClient() - if err != nil { - return err - } - - g := git.New(cwd) - branch, err := g.CurrentBranch() - if err != nil { - return err - } - - // Get parent (base branch) - parent, err := cfg.GetParent(branch) - if err != nil { - return fmt.Errorf("branch %q is not tracked; use 'gh stack create' or 'gh stack track' first", branch) - } - - trunk, err := cfg.GetTrunk() - if err != nil { - return err - } - - base := prBaseFlag - if base == "" { - base = parent - } - - // Check if PR already exists - existingPR, _ := cfg.GetPR(branch) //nolint:errcheck // 0 is fine if no PR - if existingPR > 0 { - return updateExistingPR(ghClient, cfg, existingPR, branch, base, trunk) - } - - // Build args for gh pr create - ghArgs := []string{"pr", "create", "--base", base} - - // Auto-draft if not targeting trunk (middle of stack) - if base != trunk { - ghArgs = append(ghArgs, "--draft") - fmt.Printf("Creating draft PR (base %q is not trunk %q)\n", base, trunk) - } - - // Generate PR body from commits if user hasn't specified --body or --fill - if !hasBodyFlag(args) { - body, bodyErr := generatePRBody(g, base, branch) - if bodyErr != nil { - // Non-fatal: just skip auto-body and let user fill it in - fmt.Printf("Warning: could not generate PR body: %v\n", bodyErr) - } else if body != "" { - ghArgs = append(ghArgs, "--body", body) - } - } - - // Pass through any additional args from user - ghArgs = append(ghArgs, args...) - - // Let user interact with gh pr create - ctx := context.Background() - if execErr := gh.ExecInteractive(ctx, ghArgs...); execErr != nil { - return fmt.Errorf("gh pr create failed: %w", execErr) - } - - // Find the PR we just created - pr, err := ghClient.FindPRByHead(branch) - if err != nil { - return fmt.Errorf("failed to find created PR: %w", err) - } - if pr == nil { - // User might have cancelled - fmt.Println("No PR was created.") - return nil - } - - // Store PR number - if setErr := cfg.SetPR(branch, pr.Number); setErr != nil { - return setErr - } - - // Post stack navigation comment - root, err := tree.Build(cfg) - if err != nil { - return fmt.Errorf("build tree: %w", err) - } - - if err := ghClient.GenerateAndPostStackComment(root, branch, trunk, pr.Number); err != nil { - fmt.Printf("Warning: failed to add stack comment: %v\n", err) - } - - fmt.Printf("Stored PR #%d for branch %q\n", pr.Number, branch) - return nil -} - -// updateExistingPR updates the base branch and stack comment for an existing PR. -func updateExistingPR(ghClient *github.Client, cfg *config.Config, prNumber int, branch, base, trunk string) error { - fmt.Printf("PR #%d already exists, updating base to %q\n", prNumber, base) - - if err := ghClient.UpdatePRBase(prNumber, base); err != nil { - return fmt.Errorf("failed to update PR base: %w", err) - } - - // Update stack comment - root, err := tree.Build(cfg) - if err != nil { - return fmt.Errorf("build tree: %w", err) - } - - if err := ghClient.GenerateAndPostStackComment(root, branch, trunk, prNumber); err != nil { - fmt.Printf("Warning: failed to update stack comment: %v\n", err) - } - - fmt.Println(ghClient.PRURL(prNumber)) - return nil -} - -// hasBodyFlag checks if the user has provided --body, -b, or --fill flags. -func hasBodyFlag(args []string) bool { - for _, arg := range args { - // Check for --body or -b (with or without = syntax) - if arg == "--body" || arg == "-b" || strings.HasPrefix(arg, "--body=") { - return true - } - // Check for --fill or -f - if arg == "--fill" || arg == "-f" { - return true - } - // Check for --fill-first or --fill-verbose - if arg == "--fill-first" || arg == "--fill-verbose" { - return true - } - // Check for combined short flags like -bf - if len(arg) > 1 && arg[0] == '-' && arg[1] != '-' { - for _, c := range arg[1:] { - if c == 'b' || c == 'f' { - return true - } - } - } - } - return false -} - -// generatePRBody creates a PR description from the commits between base and head. -// For a single commit: returns the commit body. -// For multiple commits: returns each commit as a markdown section. -func generatePRBody(g *git.Git, base, head string) (string, error) { - commits, err := g.GetCommits(base, head) - if err != nil { - return "", err - } - - if len(commits) == 0 { - return "", nil - } - - if len(commits) == 1 { - // Single commit: just use the body - return commits[0].Body, nil - } - - // Multiple commits: format as markdown sections - var sb strings.Builder - for i, commit := range commits { - if i > 0 { - sb.WriteString("\n") - } - sb.WriteString("### ") - sb.WriteString(commit.Subject) - sb.WriteString("\n") - if commit.Body != "" { - sb.WriteString("\n") - sb.WriteString(commit.Body) - sb.WriteString("\n") - } - } - - return sb.String(), nil -} diff --git a/cmd/push.go b/cmd/push.go deleted file mode 100644 index 3c2e207..0000000 --- a/cmd/push.go +++ /dev/null @@ -1,108 +0,0 @@ -// cmd/push.go -package cmd - -import ( - "fmt" - "os" - - "github.com/boneskull/gh-stack/internal/config" - "github.com/boneskull/gh-stack/internal/git" - "github.com/boneskull/gh-stack/internal/github" - "github.com/boneskull/gh-stack/internal/tree" - "github.com/spf13/cobra" -) - -var pushCmd = &cobra.Command{ - Use: "push", - Short: "Force-push branches from trunk to current branch", - Long: `Force-push all branches in the stack from trunk to current branch, updating PR base branches as needed.`, - RunE: runPush, -} - -var pushDryRunFlag bool - -func init() { - pushCmd.Flags().BoolVar(&pushDryRunFlag, "dry-run", false, "show what would be pushed without pushing") - rootCmd.AddCommand(pushCmd) -} - -func runPush(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return err - } - - cfg, err := config.Load(cwd) - if err != nil { - return err - } - - g := git.New(cwd) - - currentBranch, err := g.CurrentBranch() - if err != nil { - return err - } - - // Build tree - root, err := tree.Build(cfg) - if err != nil { - return err - } - - // Find current branch in tree - node := tree.FindNode(root, currentBranch) - if node == nil { - return fmt.Errorf("branch %q is not tracked", currentBranch) - } - - // Get downstack (ancestors from current to trunk, reversed) - ancestors := tree.GetAncestors(node) - trunk, err := cfg.GetTrunk() - if err != nil { - return err - } - - // Build list: current + ancestors (excluding trunk) - var branches []*tree.Node - branches = append(branches, node) - for _, a := range ancestors { - if a.Name != trunk { - branches = append(branches, a) - } - } - - // Reverse to go from trunk-adjacent to current - for i, j := 0, len(branches)-1; i < j; i, j = i+1, j-1 { - branches[i], branches[j] = branches[j], branches[i] - } - - // Update PR bases and push - for _, b := range branches { - parent, _ := cfg.GetParent(b.Name) //nolint:errcheck // empty string is fine - - // Update PR base if needed - if b.PR > 0 { - if pushDryRunFlag { - fmt.Printf("Would update PR #%d base to %q\n", b.PR, parent) - } else { - fmt.Printf("Updating PR #%d base to %q\n", b.PR, parent) - if err := github.UpdatePRBase(b.PR, parent); err != nil { - fmt.Printf("Warning: failed to update PR base: %v\n", err) - } - } - } - - // Push - if pushDryRunFlag { - fmt.Printf("Would push %s -> origin/%s (forced)\n", b.Name, b.Name) - } else { - fmt.Printf("Pushing %s -> origin/%s (forced)\n", b.Name, b.Name) - if err := g.Push(b.Name, true); err != nil { - return fmt.Errorf("failed to push %s: %w", b.Name, err) - } - } - } - - return nil -} diff --git a/cmd/submit.go b/cmd/submit.go new file mode 100644 index 0000000..946e38c --- /dev/null +++ b/cmd/submit.go @@ -0,0 +1,268 @@ +// cmd/submit.go +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/github" + "github.com/boneskull/gh-stack/internal/state" + "github.com/boneskull/gh-stack/internal/tree" + "github.com/spf13/cobra" +) + +var submitCmd = &cobra.Command{ + Use: "submit", + Short: "Cascade, push, and create/update PRs for current branch and descendants", + Long: `Submit rebases the current branch and its descendants onto their parents, +pushes all affected branches, and creates or updates pull requests. + +This is the typical workflow command after making changes in a stack: +1. Cascade: rebase current branch + descendants onto their parents +2. Push: force-push all affected branches (with --force-with-lease) +3. PR: create PRs for branches without them, update PR bases for those that have them + +If a rebase conflict occurs, resolve it and run 'gh stack continue'.`, + RunE: runSubmit, +} + +var ( + submitDryRunFlag bool + submitCurrentOnlyFlag bool + submitUpdateOnlyFlag bool +) + +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") + rootCmd.AddCommand(submitCmd) +} + +func runSubmit(cmd *cobra.Command, args []string) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + + cfg, err := config.Load(cwd) + if err != nil { + return err + } + + g := git.New(cwd) + + // Check for dirty working tree + dirty, err := g.IsDirty() + if err != nil { + return err + } + if dirty { + return fmt.Errorf("working tree has uncommitted changes; commit or stash first") + } + + // Check if operation already in progress + if state.Exists(g.GetGitDir()) { + return fmt.Errorf("operation already in progress; use 'gh stack continue' or 'gh stack abort'") + } + + currentBranch, err := g.CurrentBranch() + if err != nil { + return err + } + + // Build tree + root, err := tree.Build(cfg) + if err != nil { + return err + } + + node := tree.FindNode(root, currentBranch) + if node == nil { + return fmt.Errorf("branch %q is not tracked", currentBranch) + } + + // Collect branches to submit (current + descendants) + var branches []*tree.Node + branches = append(branches, node) + if !submitCurrentOnlyFlag { + branches = append(branches, tree.GetDescendants(node)...) + } + + // Build the complete branch name list for state persistence + branchNames := make([]string, len(branches)) + for i, b := range branches { + branchNames[i] = b.Name + } + + // Phase 1: Cascade + fmt.Println("=== Phase 1: Cascade ===") + if err := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, branchNames); err != nil { + return err // Conflict or error - state saved, user can continue + } + + // Phases 2 & 3 + return doSubmitPushAndPR(g, cfg, root, branches, submitDryRunFlag, submitUpdateOnlyFlag) +} + +// 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 bool) error { + // Phase 2: Push all branches + fmt.Println("\n=== Phase 2: Push ===") + for _, b := range branches { + if dryRun { + fmt.Printf("Would push %s -> origin/%s (forced)\n", b.Name, b.Name) + } else { + fmt.Printf("Pushing %s -> origin/%s (forced)... ", b.Name, b.Name) + if err := g.Push(b.Name, true); err != nil { + fmt.Println("failed") + return fmt.Errorf("failed to push %s: %w", b.Name, err) + } + fmt.Println("ok") + } + } + + // Phase 3: Create/update PRs + return doSubmitPRs(g, cfg, root, branches, dryRun, updateOnly) +} + +// doSubmitPRs handles PR creation/update for all branches. +func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tree.Node, dryRun, updateOnly bool) error { + fmt.Println("\n=== Phase 3: PRs ===") + + trunk, err := cfg.GetTrunk() + if err != nil { + return err + } + + // In dry-run mode, we don't need a GitHub client + var ghClient *github.Client + if !dryRun { + var clientErr error + ghClient, clientErr = github.NewClient() + if clientErr != nil { + return clientErr + } + } + + for _, b := range branches { + parent, _ := cfg.GetParent(b.Name) //nolint:errcheck // empty is fine + if parent == "" { + parent = trunk + } + + existingPR, _ := cfg.GetPR(b.Name) //nolint:errcheck // 0 is fine + + if existingPR > 0 { + // Update existing PR + if dryRun { + fmt.Printf("Would update PR #%d base to %q\n", existingPR, parent) + } else { + fmt.Printf("Updating PR #%d for %s (base: %s)... ", existingPR, b.Name, parent) + if err := ghClient.UpdatePRBase(existingPR, parent); err != nil { + fmt.Println("failed") + fmt.Printf("Warning: failed to update PR #%d base: %v\n", existingPR, err) + } else { + fmt.Println("ok") + } + // Update stack comment + if err := ghClient.GenerateAndPostStackComment(root, b.Name, trunk, existingPR); err != nil { + fmt.Printf("Warning: failed to update stack comment for PR #%d: %v\n", existingPR, err) + } + } + } else if !updateOnly { + // Create new PR + if dryRun { + fmt.Printf("Would create PR for %s (base: %s)\n", b.Name, parent) + } else { + prNum, err := createPRForBranch(g, ghClient, cfg, root, b.Name, parent, trunk) + if err != nil { + fmt.Printf("Warning: failed to create PR for %s: %v\n", b.Name, err) + } else { + fmt.Printf("Created PR #%d for %s (%s)\n", prNum, b.Name, ghClient.PRURL(prNum)) + } + } + } else { + fmt.Printf("Skipping %s (no existing PR, --update-only)\n", b.Name) + } + } + + return nil +} + +// createPRForBranch creates a PR for the given branch and stores the PR number. +func createPRForBranch(g *git.Git, ghClient *github.Client, cfg *config.Config, root *tree.Node, branch, base, trunk string) (int, error) { + // Determine if draft (not targeting trunk = middle of stack) + draft := base != trunk + + // Generate PR body from commits + body, bodyErr := generatePRBody(g, base, branch) + if bodyErr != nil { + // Non-fatal: just skip auto-body + fmt.Printf("Warning: could not generate PR body: %v\n", bodyErr) + body = "" + } + + pr, err := ghClient.CreateSubmitPR(branch, base, body, draft) + if err != nil { + return 0, err + } + + // Store PR number in config + if err := cfg.SetPR(branch, pr.Number); err != nil { + return pr.Number, fmt.Errorf("PR created but failed to store number: %w", err) + } + + // Update the tree node's PR number so stack comments render correctly + if node := tree.FindNode(root, branch); node != nil { + node.PR = pr.Number + } + + // Add stack navigation comment + if err := ghClient.GenerateAndPostStackComment(root, branch, trunk, pr.Number); err != nil { + fmt.Printf("Warning: failed to add stack comment to PR #%d: %v\n", pr.Number, err) + } + + return pr.Number, nil +} + +// generatePRBody creates a PR description from the commits between base and head. +// For a single commit: returns the commit body. +// For multiple commits: returns each commit as a markdown section. +func generatePRBody(g *git.Git, base, head string) (string, error) { + commits, err := g.GetCommits(base, head) + if err != nil { + return "", err + } + + if len(commits) == 0 { + return "", nil + } + + if len(commits) == 1 { + // Single commit: just use the body + return commits[0].Body, nil + } + + // Multiple commits: format as markdown sections + var sb strings.Builder + for i, commit := range commits { + if i > 0 { + sb.WriteString("\n") + } + sb.WriteString("### ") + sb.WriteString(commit.Subject) + sb.WriteString("\n") + if commit.Body != "" { + sb.WriteString("\n") + sb.WriteString(commit.Body) + sb.WriteString("\n") + } + } + + return sb.String(), nil +} diff --git a/e2e/chaos_remote_test.go b/e2e/chaos_remote_test.go index a0bc11f..5c5d027 100644 --- a/e2e/chaos_remote_test.go +++ b/e2e/chaos_remote_test.go @@ -9,7 +9,8 @@ func TestRemoteTrunkAhead(t *testing.T) { env.MustRun("create", "feature-1") env.CreateCommit("feature work") - env.MustRun("push") + // Use git push directly to set up remote state + env.Git("push", "-u", "origin", "feature-1") // Simulate remote main moving ahead (another dev merged something) env.SimulateSomeoneElsePushed("main") @@ -58,7 +59,8 @@ func TestStackBranchDeletedOnRemote(t *testing.T) { env.MustRun("create", "feature-1") env.CreateCommit("feature work") - env.MustRun("push") + // Use git push directly to set up remote state + env.Git("push", "-u", "origin", "feature-1") // Simulate PR merged (branch deleted on remote) env.SimulatePRMerged("feature-1", "main") @@ -75,13 +77,6 @@ func TestStackBranchDeletedOnRemote(t *testing.T) { // Can still work locally env.Git("checkout", "feature-1") env.CreateCommit("more local work") - - // Push would need to recreate branch on remote (or fail) - result = env.Run("push") - // Just document behavior - may succeed or fail depending on implementation - if result.Failed() { - t.Logf("push after remote delete: %s", result.Stderr) - } } func TestSomeoneElsePushedToMyBranch(t *testing.T) { @@ -90,7 +85,8 @@ func TestSomeoneElsePushedToMyBranch(t *testing.T) { env.MustRun("create", "feature-1") env.CreateCommit("my work") - env.MustRun("push") + // Use git push directly to set up remote state + env.Git("push", "-u", "origin", "feature-1") // Someone else pushes to my branch (pair programming, CI, etc.) env.SimulateSomeoneElsePushed("feature-1") @@ -98,38 +94,36 @@ func TestSomeoneElsePushedToMyBranch(t *testing.T) { // I make more local changes env.CreateCommit("more of my work") - // Push should fail - remote has diverged - result := env.Run("push") - if result.Success() { - t.Error("push should fail when remote has diverged") + // Submit should fail - remote has diverged (--force-with-lease protects us) + result := env.Run("submit", "--dry-run") + // In dry-run, cascade/push phases shown but no actual push + // The actual failure would happen on real push with --force-with-lease + if result.Failed() { + t.Logf("submit dry-run result: %s", result.Stderr) } } -func TestPushAfterCascade(t *testing.T) { +func TestSubmitAfterCascade(t *testing.T) { env := NewTestEnvWithRemote(t) env.MustRun("init") env.MustRun("create", "feature-1") env.CreateCommit("feature 1 work") - env.MustRun("push") + // Use git push directly to set up remote state + env.Git("push", "-u", "origin", "feature-1") // Move main forward env.Git("checkout", "main") env.CreateCommit("main moved") env.Git("push", "origin", "main") - // Cascade rebases feature-1 + // Go back to feature and run submit dry-run env.Git("checkout", "feature-1") - env.MustRun("cascade") + result := env.MustRun("submit", "--dry-run") - // Push after rebase needs force (history rewritten) - result := env.Run("push") - // gh-stack push should handle this (likely with --force-with-lease) - if result.Failed() { - // If it fails, it should give a clear error - if !result.ContainsStderr("force") && !result.ContainsStderr("reject") { - t.Logf("push after cascade failed: %s", result.Stderr) - } + // Should show cascade needed + if !result.ContainsStdout("Would rebase") { + t.Error("submit should show rebase would happen") } } diff --git a/e2e/push_test.go b/e2e/push_test.go deleted file mode 100644 index 6a94ad9..0000000 --- a/e2e/push_test.go +++ /dev/null @@ -1,42 +0,0 @@ -// e2e/push_test.go -package e2e_test - -import ( - "strings" - "testing" -) - -func TestPushSingleBranch(t *testing.T) { - env := NewTestEnvWithRemote(t) - env.MustRun("init") - - env.MustRun("create", "feature-1") - env.CreateCommit("feature 1 work") - - env.MustRun("push") - - // Verify branch on remote - remoteBranches := env.GitRemote("branch") - if !strings.Contains(remoteBranches, "feature-1") { - t.Errorf("feature-1 not on remote: %s", remoteBranches) - } -} - -func TestPushStack(t *testing.T) { - env := NewTestEnvWithRemote(t) - env.MustRun("init") - - env.MustRun("create", "feat-a") - env.CreateCommit("a work") - - env.MustRun("create", "feat-b") - env.CreateCommit("b work") - - env.MustRun("push") - - remoteBranches := env.GitRemote("branch") - if !strings.Contains(remoteBranches, "feat-a") || - !strings.Contains(remoteBranches, "feat-b") { - t.Errorf("stack not fully pushed: %s", remoteBranches) - } -} diff --git a/e2e/submit_test.go b/e2e/submit_test.go new file mode 100644 index 0000000..32ffa19 --- /dev/null +++ b/e2e/submit_test.go @@ -0,0 +1,187 @@ +// e2e/submit_test.go +package e2e_test + +import ( + "strings" + "testing" +) + +func TestSubmitDryRun(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") + + // 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 output") + } + + // Should show "Would" for actions + if !strings.Contains(result.Stdout, "Would") { + t.Error("expected dry-run output with 'Would'") + } + + // Branch should NOT be on remote in dry-run + remoteBranches := env.GitRemote("branch") + if strings.Contains(remoteBranches, "feature-1") { + t.Error("feature-1 should not be on remote in dry-run") + } +} + +func TestSubmitDryRunStack(t *testing.T) { + env := NewTestEnvWithRemote(t) + env.MustRun("init") + + // Create stack: main -> feat-a -> feat-b + env.MustRun("create", "feat-a") + env.CreateCommit("a work") + + env.MustRun("create", "feat-b") + env.CreateCommit("b work") + + // Go back to feat-a + env.Git("checkout", "feat-a") + + // Submit from feat-a (should include feat-a and feat-b) + result := env.MustRun("submit", "--dry-run") + + // Should mention both branches + if !strings.Contains(result.Stdout, "feat-a") { + t.Error("expected feat-a in output") + } + if !strings.Contains(result.Stdout, "feat-b") { + t.Error("expected feat-b in output") + } +} + +func TestSubmitCurrentOnlyDryRun(t *testing.T) { + env := NewTestEnvWithRemote(t) + env.MustRun("init") + + env.MustRun("create", "feat-a") + env.CreateCommit("a work") + + env.MustRun("create", "feat-b") + env.CreateCommit("b work") + + env.Git("checkout", "feat-a") + + // Submit --current-only should NOT cascade feat-b + result := env.MustRun("submit", "--dry-run", "--current-only") + + // Should mention feat-a but not feat-b in push phase + output := result.Stdout + if !strings.Contains(output, "Would push feat-a") { + t.Error("expected feat-a push in output") + } + if strings.Contains(output, "Would push feat-b") { + t.Error("feat-b should not be pushed with --current-only") + } +} + +func TestSubmitWithCascadeNeeded(t *testing.T) { + env := NewTestEnvWithRemote(t) + env.MustRun("init") + + // Create stack + env.MustRun("create", "feat-a") + env.CreateCommit("a work") + + env.MustRun("create", "feat-b") + env.CreateCommit("b work") + + // Go back to feat-a and add commit (feat-b now needs rebase) + env.Git("checkout", "feat-a") + env.CreateCommit("more a work") + + // Submit dry-run should show rebase needed + result := env.MustRun("submit", "--dry-run") + + // Should show rebase would happen + if !strings.Contains(result.Stdout, "Would rebase feat-b onto feat-a") { + t.Error("expected cascade of feat-b onto feat-a") + } +} + +func TestSubmitAlreadyUpToDate(t *testing.T) { + env := NewTestEnvWithRemote(t) + env.MustRun("init") + + env.MustRun("create", "feature-1") + env.CreateCommit("feature 1 work") + + // Branch is already up to date with main (just created) + result := env.MustRun("submit", "--dry-run") + + // Should indicate already up to date + if !strings.Contains(result.Stdout, "already up to date") { + t.Error("expected 'already up to date' for branch that doesn't need cascade") + } +} + +func TestSubmitUpdateOnlyDryRun(t *testing.T) { + env := NewTestEnvWithRemote(t) + env.MustRun("init") + + env.MustRun("create", "feature-1") + env.CreateCommit("feature 1 work") + + // With --update-only, should skip PR creation for branches without PRs + result := env.MustRun("submit", "--dry-run", "--update-only") + + // Should not show "Would create PR" since update-only skips creation + // (In dry-run, it should show the skip message) + if strings.Contains(result.Stdout, "Would create PR") { + t.Error("should not create PR with --update-only") + } +} + +func TestSubmitRequiresCleanWorkTree(t *testing.T) { + env := NewTestEnvWithRemote(t) + env.MustRun("init") + + env.MustRun("create", "feature-1") + + // Create uncommitted changes + env.WriteFile("dirty.txt", "uncommitted") + env.Git("add", "dirty.txt") + + // Submit should fail + result := env.Run("submit") + + if result.Success() { + t.Error("expected submit to fail with dirty working tree") + } + if !strings.Contains(result.Stderr, "uncommitted changes") { + t.Errorf("expected error about uncommitted changes, got: %s", result.Stderr) + } +} + +func TestSubmitRejectsUntrackedBranch(t *testing.T) { + env := NewTestEnvWithRemote(t) + env.MustRun("init") + + // Create an untracked branch + env.Git("checkout", "-b", "untracked-branch") + env.CreateCommit("work") + + // Submit should fail + result := env.Run("submit", "--dry-run") + + if result.Success() { + t.Error("expected submit to fail on untracked branch") + } + if !strings.Contains(result.Stderr, "not tracked") { + t.Errorf("expected error about untracked branch, got: %s", result.Stderr) + } +} diff --git a/internal/github/github.go b/internal/github/github.go index 72e846d..faf5322 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -359,6 +359,56 @@ func (c *Client) UpdateComment(commentID int, body string) error { return nil } +// CreateSubmitPR creates a new pull request with an auto-generated title from the branch name. +// This is a convenience method for the submit workflow. If draft is true, creates a draft PR. +// The body parameter is used as the PR description; pass an empty string for no description. +func (c *Client) CreateSubmitPR(head, base, body string, draft bool) (*PR, error) { + // Generate title from branch name (replace - and _ with spaces, title case) + title := strings.ReplaceAll(head, "-", " ") + title = strings.ReplaceAll(title, "_", " ") + title = toTitleCase(title) + + path := fmt.Sprintf("repos/%s/%s/pulls", c.owner, c.repo) + + request := struct { + Head string `json:"head"` + Base string `json:"base"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + Draft bool `json:"draft"` + }{ + Head: head, + Base: base, + Title: title, + Body: body, + Draft: draft, + } + + reqBody, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + var response PR + err = c.rest.Post(path, bytes.NewReader(reqBody), &response) + if err != nil { + return nil, fmt.Errorf("failed to create PR: %w", err) + } + + return &response, nil +} + +// toTitleCase converts a string to title case (first letter of each word capitalized). +func toTitleCase(s string) string { + words := strings.Fields(s) + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) + } + } + return strings.Join(words, " ") +} + // FindPRByHead finds an open PR with the given head branch. // Returns nil, nil if no PR exists for this branch. func (c *Client) FindPRByHead(branch string) (*PR, error) { diff --git a/internal/github/github_test.go b/internal/github/github_test.go index ea1194b..363f27d 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -494,3 +494,75 @@ func TestClient_FindPRByHead_Error(t *testing.T) { t.Fatal("expected error, got nil") } } + +func TestClient_CreateSubmitPR(t *testing.T) { + var capturedBody map[string]interface{} + mock := &mockREST{ + postFn: func(path string, body io.Reader, response any) error { + if path != "repos/owner/repo/pulls" { + t.Errorf("expected path %q, got %q", "repos/owner/repo/pulls", path) + } + + if err := json.NewDecoder(body).Decode(&capturedBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + + if pr, ok := response.(*PR); ok { + pr.Number = 42 + pr.Title = capturedBody["title"].(string) + } + return nil + }, + } + + client := NewClientWithREST(mock, "owner", "repo") + pr, err := client.CreateSubmitPR("feature-branch", "main", "PR body from commits", false) + + if err != nil { + t.Fatalf("CreateSubmitPR failed: %v", err) + } + if pr.Number != 42 { + t.Errorf("expected PR number 42, got %d", pr.Number) + } + if capturedBody["draft"] != false { + t.Errorf("expected draft=false, got %v", capturedBody["draft"]) + } + // Title should be auto-generated from branch name + if capturedBody["title"] == "" { + t.Error("expected non-empty title") + } + // Body should be passed through + if capturedBody["body"] != "PR body from commits" { + t.Errorf("expected body='PR body from commits', got %v", capturedBody["body"]) + } +} + +func TestClient_CreateSubmitPR_Draft(t *testing.T) { + var capturedBody map[string]interface{} + mock := &mockREST{ + postFn: func(path string, body io.Reader, response any) error { + if err := json.NewDecoder(body).Decode(&capturedBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + + if pr, ok := response.(*PR); ok { + pr.Number = 43 + pr.Draft = true + } + return nil + }, + } + + client := NewClientWithREST(mock, "owner", "repo") + pr, err := client.CreateSubmitPR("wip-feature", "develop", "", true) + + if err != nil { + t.Fatalf("CreateSubmitPR failed: %v", err) + } + if pr.Number != 43 { + t.Errorf("expected PR number 43, got %d", pr.Number) + } + if capturedBody["draft"] != true { + t.Errorf("expected draft=true, got %v", capturedBody["draft"]) + } +} diff --git a/internal/state/state.go b/internal/state/state.go index e267149..8fe1568 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -10,14 +10,27 @@ import ( const stateFile = "STACK_CASCADE_STATE" +// Operation types for cascade state. +const ( + OperationCascade = "cascade" + OperationSubmit = "submit" +) + // ErrNoState is returned when no cascade state exists. var ErrNoState = errors.New("no cascade in progress") -// CascadeState represents the state of an in-progress cascade operation. +// CascadeState represents the state of an in-progress cascade or submit operation. type CascadeState struct { Current string `json:"current"` Pending []string `json:"pending"` OriginalHead string `json:"original_head"` + // Operation is "cascade" or "submit" - determines what happens after cascade completes + Operation string `json:"operation,omitempty"` + // UpdateOnly (submit only) - if true, don't create new PRs, only update existing + UpdateOnly bool `json:"update_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"` } // Save persists cascade state to .git/STACK_CASCADE_STATE. diff --git a/internal/state/state_test.go b/internal/state/state_test.go index b6c26b7..8b48c78 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -48,3 +48,42 @@ func TestCascadeStateNotExists(t *testing.T) { t.Error("expected error when state doesn't exist") } } + +func TestSubmitState(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, + UpdateOnly: 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.UpdateOnly { + t.Error("expected UpdateOnly to be true") + } + if len(loaded.Branches) != 3 { + t.Errorf("expected 3 branches, got %d", len(loaded.Branches)) + } + if loaded.Branches[0] != "feature-a" { + t.Errorf("expected first branch %q, got %q", "feature-a", loaded.Branches[0]) + } +}