Skip to content

Commit 40adc81

Browse files
committed
feat(cmd): add submit command for unified cascade, push, and PR workflow
Adds 'gh stack submit' which combines three phases into one command: 1. Cascade: rebase current branch and descendants onto their parents 2. Push: force-push all affected branches with --force-with-lease 3. PRs: create PRs for new branches (as drafts if mid-stack), update bases for existing Supports --dry-run, --current-only, and --update-only flags. The continue command now handles submit operations, resuming push/PR phases after conflict resolution. Includes state extensions (Operation, UpdateOnly fields), CreateSubmitPR method with auto-generated titles, comprehensive e2e tests, and README documentation.
1 parent 1d0c001 commit 40adc81

9 files changed

Lines changed: 656 additions & 21 deletions

File tree

README.md

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,50 @@ gh stack continue
100100
gh stack abort
101101
```
102102

103+
### Submit Your Stack
104+
105+
```bash
106+
gh stack submit
107+
```
108+
109+
The all-in-one command for getting your work onto GitHub. Submit:
110+
111+
1. **Cascades** the current branch and its descendants onto their parents
112+
2. **Pushes** all affected branches with `--force-with-lease`
113+
3. **Creates/updates PRs** for each branch (creates as draft if mid-stack)
114+
115+
This is typically what you run after making changes:
116+
117+
```bash
118+
# Make some changes
119+
git add . && git commit -m "fix: address review feedback"
120+
121+
# Ship it
122+
gh stack submit
123+
```
124+
125+
#### Flags
126+
127+
| Flag | Description |
128+
| ---------------- | ------------------------------------------------ |
129+
| `--dry-run` | Show what would happen without doing it |
130+
| `--current-only` | Only submit the current branch, not descendants |
131+
| `--update-only` | Only update existing PRs, don't create new ones |
132+
133+
#### Conflict Resolution
134+
135+
If a rebase conflict occurs during submit:
136+
137+
```bash
138+
# Resolve the conflicts, then:
139+
gh stack continue
140+
141+
# Or abort:
142+
gh stack abort
143+
```
144+
145+
After continuing, submit resumes with push and PR phases.
146+
103147
### Sync Everything
104148

105149
```bash
@@ -121,9 +165,10 @@ Fetches from origin, fast-forwards trunk, detects merged PRs, cleans up merged b
121165
| `unlink` | Remove PR association |
122166
| `pr` | Create or update PR targeting parent |
123167
| `push` | Force-push stack with `--force-with-lease` |
168+
| `submit` | Cascade, push, and create/update PRs in one command |
124169
| `cascade` | Rebase branch and descendants onto parents |
125-
| `continue` | Resume cascade after conflict resolution |
126-
| `abort` | Cancel cascade operation |
170+
| `continue` | Resume operation after conflict resolution |
171+
| `abort` | Cancel in-progress operation |
127172
| `sync` | Full sync: fetch, cleanup merged PRs, cascade all |
128173

129174
## How It Works

cmd/cascade.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ func runCascade(cmd *cobra.Command, args []string) error {
8888
}
8989

9090
func doCascade(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool) error {
91+
return doCascadeWithState(g, cfg, branches, dryRun, state.OperationCascade, false)
92+
}
93+
94+
// doCascadeWithState performs cascade and saves state with the given operation type.
95+
func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly bool) error {
9196
originalBranch, err := g.CurrentBranch()
9297
if err != nil {
9398
return err
@@ -162,6 +167,8 @@ func doCascade(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun boo
162167
Current: b.Name,
163168
Pending: remaining,
164169
OriginalHead: originalHead,
170+
Operation: operation,
171+
UpdateOnly: updateOnly,
165172
}
166173
_ = state.Save(g.GetGitDir(), st) //nolint:errcheck // best effort - user can recover manually
167174

cmd/continue.go

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import (
1414

1515
var continueCmd = &cobra.Command{
1616
Use: "continue",
17-
Short: "Continue a cascade after resolving conflicts",
18-
Long: `Continue a cascade operation after resolving rebase conflicts.`,
17+
Short: "Continue an operation after resolving conflicts",
18+
Long: `Continue a cascade or submit operation after resolving rebase conflicts.`,
1919
RunE: runContinue,
2020
}
2121

@@ -31,10 +31,10 @@ func runContinue(cmd *cobra.Command, args []string) error {
3131

3232
g := git.New(cwd)
3333

34-
// Check if cascade in progress
34+
// Check if operation in progress
3535
st, err := state.Load(g.GetGitDir())
3636
if err != nil {
37-
return fmt.Errorf("no cascade in progress")
37+
return fmt.Errorf("no operation in progress")
3838
}
3939

4040
// Complete the in-progress rebase
@@ -47,13 +47,6 @@ func runContinue(cmd *cobra.Command, args []string) error {
4747

4848
fmt.Printf("Completed %s\n", st.Current)
4949

50-
// Continue with remaining branches
51-
if len(st.Pending) == 0 {
52-
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup
53-
fmt.Println("Cascade complete!")
54-
return nil
55-
}
56-
5750
cfg, err := config.Load(cwd)
5851
if err != nil {
5952
return err
@@ -65,15 +58,45 @@ func runContinue(cmd *cobra.Command, args []string) error {
6558
return err
6659
}
6760

68-
var branches []*tree.Node
69-
for _, name := range st.Pending {
70-
if node := tree.FindNode(root, name); node != nil {
71-
branches = append(branches, node)
61+
// If there are more branches to cascade, continue cascading
62+
if len(st.Pending) > 0 {
63+
var branches []*tree.Node
64+
for _, name := range st.Pending {
65+
if node := tree.FindNode(root, name); node != nil {
66+
branches = append(branches, node)
67+
}
68+
}
69+
70+
// Remove state file before continuing (will be recreated if conflict)
71+
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup
72+
73+
if err := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly); err != nil {
74+
return err // Another conflict - state saved
7275
}
76+
} else {
77+
// No more branches to cascade - cleanup state
78+
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup
7379
}
7480

75-
// Remove state file before continuing (will be recreated if conflict)
76-
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup
81+
// If this was a submit operation, continue with push + PR phases
82+
if st.Operation == state.OperationSubmit {
83+
// Rebuild branches list: current + all that were pending (now completed)
84+
currentNode := tree.FindNode(root, st.Current)
85+
if currentNode == nil {
86+
return fmt.Errorf("branch %q not found in tree", st.Current)
87+
}
88+
89+
var allBranches []*tree.Node
90+
allBranches = append(allBranches, currentNode)
91+
for _, name := range st.Pending {
92+
if node := tree.FindNode(root, name); node != nil {
93+
allBranches = append(allBranches, node)
94+
}
95+
}
96+
97+
return doSubmitPushAndPR(g, cfg, root, allBranches, false, st.UpdateOnly)
98+
}
7799

78-
return doCascade(g, cfg, branches, false)
100+
fmt.Println("Cascade complete!")
101+
return nil
79102
}

cmd/submit.go

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// cmd/submit.go
2+
package cmd
3+
4+
import (
5+
"fmt"
6+
"os"
7+
8+
"github.com/boneskull/gh-stack/internal/config"
9+
"github.com/boneskull/gh-stack/internal/git"
10+
"github.com/boneskull/gh-stack/internal/github"
11+
"github.com/boneskull/gh-stack/internal/state"
12+
"github.com/boneskull/gh-stack/internal/tree"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var submitCmd = &cobra.Command{
17+
Use: "submit",
18+
Short: "Cascade, push, and create/update PRs for current branch and descendants",
19+
Long: `Submit rebases the current branch and its descendants onto their parents,
20+
pushes all affected branches, and creates or updates pull requests.
21+
22+
This is the typical workflow command after making changes in a stack:
23+
1. Cascade: rebase current branch + descendants onto their parents
24+
2. Push: force-push all affected branches (with --force-with-lease)
25+
3. PR: create PRs for branches without them, update PR bases for those that have them
26+
27+
If a rebase conflict occurs, resolve it and run 'gh stack continue'.`,
28+
RunE: runSubmit,
29+
}
30+
31+
var (
32+
submitDryRunFlag bool
33+
submitCurrentOnlyFlag bool
34+
submitUpdateOnlyFlag bool
35+
)
36+
37+
func init() {
38+
submitCmd.Flags().BoolVar(&submitDryRunFlag, "dry-run", false, "show what would be done without doing it")
39+
submitCmd.Flags().BoolVar(&submitCurrentOnlyFlag, "current-only", false, "only submit current branch, not descendants")
40+
submitCmd.Flags().BoolVar(&submitUpdateOnlyFlag, "update-only", false, "only update existing PRs, don't create new ones")
41+
rootCmd.AddCommand(submitCmd)
42+
}
43+
44+
func runSubmit(cmd *cobra.Command, args []string) error {
45+
cwd, err := os.Getwd()
46+
if err != nil {
47+
return err
48+
}
49+
50+
cfg, err := config.Load(cwd)
51+
if err != nil {
52+
return err
53+
}
54+
55+
g := git.New(cwd)
56+
57+
// Check for dirty working tree
58+
dirty, err := g.IsDirty()
59+
if err != nil {
60+
return err
61+
}
62+
if dirty {
63+
return fmt.Errorf("working tree has uncommitted changes; commit or stash first")
64+
}
65+
66+
// Check if operation already in progress
67+
if state.Exists(g.GetGitDir()) {
68+
return fmt.Errorf("operation already in progress; use 'gh stack continue' or 'gh stack abort'")
69+
}
70+
71+
currentBranch, err := g.CurrentBranch()
72+
if err != nil {
73+
return err
74+
}
75+
76+
// Build tree
77+
root, err := tree.Build(cfg)
78+
if err != nil {
79+
return err
80+
}
81+
82+
node := tree.FindNode(root, currentBranch)
83+
if node == nil {
84+
return fmt.Errorf("branch %q is not tracked", currentBranch)
85+
}
86+
87+
// Collect branches to submit (current + descendants)
88+
var branches []*tree.Node
89+
branches = append(branches, node)
90+
if !submitCurrentOnlyFlag {
91+
branches = append(branches, tree.GetDescendants(node)...)
92+
}
93+
94+
// Phase 1: Cascade
95+
fmt.Println("=== Phase 1: Cascade ===")
96+
if err := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag); err != nil {
97+
return err // Conflict or error - state saved, user can continue
98+
}
99+
100+
// Phases 2 & 3
101+
return doSubmitPushAndPR(g, cfg, root, branches, submitDryRunFlag, submitUpdateOnlyFlag)
102+
}
103+
104+
// doSubmitPushAndPR handles push and PR creation/update phases.
105+
// This is called after cascade succeeds (or from continue after conflict resolution).
106+
func doSubmitPushAndPR(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tree.Node, dryRun, updateOnly bool) error {
107+
// Phase 2: Push all branches
108+
fmt.Println("\n=== Phase 2: Push ===")
109+
for _, b := range branches {
110+
if dryRun {
111+
fmt.Printf("Would push %s -> origin/%s (forced)\n", b.Name, b.Name)
112+
} else {
113+
fmt.Printf("Pushing %s -> origin/%s (forced)... ", b.Name, b.Name)
114+
if err := g.Push(b.Name, true); err != nil {
115+
fmt.Println("failed")
116+
return fmt.Errorf("failed to push %s: %w", b.Name, err)
117+
}
118+
fmt.Println("ok")
119+
}
120+
}
121+
122+
// Phase 3: Create/update PRs
123+
return doSubmitPRs(cfg, root, branches, dryRun, updateOnly)
124+
}
125+
126+
// doSubmitPRs handles PR creation/update for all branches.
127+
func doSubmitPRs(cfg *config.Config, root *tree.Node, branches []*tree.Node, dryRun, updateOnly bool) error {
128+
fmt.Println("\n=== Phase 3: PRs ===")
129+
130+
trunk, err := cfg.GetTrunk()
131+
if err != nil {
132+
return err
133+
}
134+
135+
// In dry-run mode, we don't need a GitHub client
136+
var ghClient *github.Client
137+
if !dryRun {
138+
var clientErr error
139+
ghClient, clientErr = github.NewClient()
140+
if clientErr != nil {
141+
return clientErr
142+
}
143+
}
144+
145+
for _, b := range branches {
146+
parent, _ := cfg.GetParent(b.Name) //nolint:errcheck // empty is fine
147+
if parent == "" {
148+
parent = trunk
149+
}
150+
151+
existingPR, _ := cfg.GetPR(b.Name) //nolint:errcheck // 0 is fine
152+
153+
if existingPR > 0 {
154+
// Update existing PR
155+
if dryRun {
156+
fmt.Printf("Would update PR #%d base to %q\n", existingPR, parent)
157+
} else {
158+
fmt.Printf("Updating PR #%d for %s (base: %s)... ", existingPR, b.Name, parent)
159+
if err := ghClient.UpdatePRBase(existingPR, parent); err != nil {
160+
fmt.Println("failed")
161+
fmt.Printf("Warning: failed to update PR #%d base: %v\n", existingPR, err)
162+
} else {
163+
fmt.Println("ok")
164+
}
165+
// Update stack comment
166+
if err := ghClient.GenerateAndPostStackComment(root, b.Name, trunk, existingPR); err != nil {
167+
fmt.Printf("Warning: failed to update stack comment for PR #%d: %v\n", existingPR, err)
168+
}
169+
}
170+
} else if !updateOnly {
171+
// Create new PR
172+
if dryRun {
173+
fmt.Printf("Would create PR for %s (base: %s)\n", b.Name, parent)
174+
} else {
175+
prNum, err := createPRForBranch(ghClient, cfg, root, b.Name, parent, trunk)
176+
if err != nil {
177+
fmt.Printf("Warning: failed to create PR for %s: %v\n", b.Name, err)
178+
} else {
179+
fmt.Printf("Created PR #%d for %s (%s)\n", prNum, b.Name, ghClient.PRURL(prNum))
180+
}
181+
}
182+
} else {
183+
fmt.Printf("Skipping %s (no existing PR, --update-only)\n", b.Name)
184+
}
185+
}
186+
187+
return nil
188+
}
189+
190+
// createPRForBranch creates a PR for the given branch and stores the PR number.
191+
func createPRForBranch(ghClient *github.Client, cfg *config.Config, root *tree.Node, branch, base, trunk string) (int, error) {
192+
// Determine if draft (not targeting trunk = middle of stack)
193+
draft := base != trunk
194+
195+
pr, err := ghClient.CreateSubmitPR(branch, base, draft)
196+
if err != nil {
197+
return 0, err
198+
}
199+
200+
// Store PR number in config
201+
if err := cfg.SetPR(branch, pr.Number); err != nil {
202+
return pr.Number, fmt.Errorf("PR created but failed to store number: %w", err)
203+
}
204+
205+
// Update the tree node's PR number so stack comments render correctly
206+
if node := tree.FindNode(root, branch); node != nil {
207+
node.PR = pr.Number
208+
}
209+
210+
// Add stack navigation comment
211+
if err := ghClient.GenerateAndPostStackComment(root, branch, trunk, pr.Number); err != nil {
212+
fmt.Printf("Warning: failed to add stack comment to PR #%d: %v\n", pr.Number, err)
213+
}
214+
215+
return pr.Number, nil
216+
}

0 commit comments

Comments
 (0)