@@ -129,167 +129,45 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
129129 return nil
130130 }
131131
132- // When a modify is pending, delete the old stack BEFORE pushing so that
133- // PRs aren't auto-merged/closed during the force-push.
134- pendingModify := false
132+ // If a modify is pending, delete the old remote stack first so that
133+ // PR base updates are allowed and force-pushes don't trigger auto-merges.
135134 if stacksAvailable {
136- if state , _ := modify .LoadState (gitDir ); state != nil && state .Phase == "pending_submit" {
137- pendingModify = true
138- if err := handlePendingModify (cfg , client , s , gitDir ); err != nil {
139- if errors .Is (err , errInterrupt ) {
140- return ErrSilent
141- }
142- }
143- }
144- }
145-
146- if pendingModify {
147- // Push branches one at a time in stack order (bottom to top) and
148- // update each PR's base immediately after pushing. This prevents
149- // false "merge" detections when branches are pushed simultaneously
150- // with stale base relationships.
151- cfg .Printf ("Pushing %d %s to %s (sequentially)..." , len (activeBranches ), plural (len (activeBranches ), "branch" , "branches" ), remote )
152- for i , b := range s .Branches {
153- if s .Branches [i ].IsMerged () || s .Branches [i ].IsQueued () {
154- continue
155- }
156-
157- // Push this single branch
158- if err := git .Push (remote , []string {b .Branch }, true , false ); err != nil {
159- cfg .Errorf ("failed to push %s: %s" , b .Branch , err )
135+ if err := handlePendingModify (cfg , client , s , gitDir ); err != nil {
136+ if errors .Is (err , errInterrupt ) {
160137 return ErrSilent
161138 }
162-
163- // Update PR base immediately after pushing
164- baseBranch := s .ActiveBaseBranch (b .Branch )
165- pr , prErr := client .FindPRForBranch (b .Branch )
166- if prErr != nil {
167- continue
168- }
169- if pr != nil {
170- if s .Branches [i ].PullRequest == nil {
171- s .Branches [i ].PullRequest = & stack.PullRequestRef {
172- Number : pr .Number ,
173- ID : pr .ID ,
174- URL : pr .URL ,
175- }
176- }
177- if pr .BaseRefName != baseBranch {
178- if err := client .UpdatePRBase (pr .Number , baseBranch ); err != nil {
179- cfg .Warningf ("failed to update base branch for PR %s: %v" ,
180- cfg .PRLink (pr .Number , pr .URL ), err )
181- } else {
182- cfg .Successf ("Updated base branch for PR %s to %s" ,
183- cfg .PRLink (pr .Number , pr .URL ), baseBranch )
184- }
185- } else {
186- cfg .Printf ("PR %s for %s is up to date" , cfg .PRLink (pr .Number , pr .URL ), b .Branch )
187- }
188- } else {
189- // No PR yet — will be created in the PR loop below
190- cfg .Printf ("Pushed %s" , b .Branch )
191- }
192- }
193- } else {
194- // Normal submit — push all branches atomically
195- cfg .Printf ("Pushing %d %s to %s..." , len (activeBranches ), plural (len (activeBranches ), "branch" , "branches" ), remote )
196- if err := git .Push (remote , activeBranches , true , true ); err != nil {
197- cfg .Errorf ("failed to push: %s" , err )
198- return ErrSilent
199139 }
200140 }
201141
202- // Create or update PRs — ensure every active branch has a PR with the
203- // correct base branch. This makes submit idempotent: running it again
204- // fills gaps and fixes base branches before syncing the stack.
142+ // Push each branch and create/update its PR in stack order (bottom to top).
143+ // Sequential pushing ensures each branch's base is up-to-date on the
144+ // remote before the next branch is pushed, preventing race conditions.
145+ cfg .Printf ("Pushing %d %s to %s..." , len (activeBranches ), plural (len (activeBranches ), "branch" , "branches" ), remote )
205146 for i , b := range s .Branches {
206147 if s .Branches [i ].IsMerged () || s .Branches [i ].IsQueued () {
207148 continue
208149 }
209- baseBranch := s .ActiveBaseBranch (b .Branch )
210150
211- pr , err := client . FindPRForBranch ( b . Branch )
212- if err != nil {
213- cfg .Warningf ("failed to check PR for %s: %v " , b .Branch , err )
214- continue
151+ // Push this branch
152+ if err := git . Push ( remote , [] string { b . Branch }, true , false ); err != nil {
153+ cfg .Errorf ("failed to push %s: %s " , b .Branch , err )
154+ return ErrSilent
215155 }
216156
217- if pr == nil {
218- // Create new PR — auto-generate title from commits/branch name,
219- // then prompt interactively unless --auto or non-interactive.
220- baseBranchForDiff := s .ActiveBaseBranch (b .Branch )
221- title , commitBody := defaultPRTitleBody (baseBranchForDiff , b .Branch )
222- originalTitle := title
223- if ! opts .auto && cfg .IsInteractive () {
224- p := prompter .New (cfg .In , cfg .Out , cfg .Err )
225- input , err := p .Input (fmt .Sprintf ("Title for PR (branch %s):" , b .Branch ), title )
226- if err != nil {
227- if isInterruptError (err ) {
228- printInterrupt (cfg )
229- return ErrSilent
230- }
231- // Non-interrupt error: keep the auto-generated title.
232- } else if input != "" {
233- title = input
234- }
235- }
236-
237- // If the user changed the title and the commit had a multi-line
238- // message, put the full commit message in the PR body so no
239- // content is lost.
240- prBody := commitBody
241- if title != originalTitle && commitBody != "" {
242- prBody = originalTitle + "\n \n " + commitBody
243- }
244- body := generatePRBody (prBody )
245-
246- newPR , createErr := client .CreatePR (baseBranch , b .Branch , title , body , opts .draft )
247- if createErr != nil {
248- cfg .Warningf ("failed to create PR for %s: %v" , b .Branch , createErr )
249- continue
250- }
251- cfg .Successf ("Created PR %s for %s" , cfg .PRLink (newPR .Number , newPR .URL ), b .Branch )
252- s .Branches [i ].PullRequest = & stack.PullRequestRef {
253- Number : newPR .Number ,
254- ID : newPR .ID ,
255- URL : newPR .URL ,
256- }
257- } else {
258- // PR already exists — record it and fix base branch if needed.
259- if s .Branches [i ].PullRequest == nil {
260- s .Branches [i ].PullRequest = & stack.PullRequestRef {
261- Number : pr .Number ,
262- ID : pr .ID ,
263- URL : pr .URL ,
264- }
265- }
266-
267- if pendingModify {
268- // Base updates already handled in the sequential push loop.
269- cfg .Printf ("PR %s for %s is up to date" , cfg .PRLink (pr .Number , pr .URL ), b .Branch )
270- } else if pr .BaseRefName != baseBranch {
271- if s .ID != "" {
272- cfg .Warningf ("PR %s has base %q (expected %q) but cannot update while stacked" ,
273- cfg .PRLink (pr .Number , pr .URL ), pr .BaseRefName , baseBranch )
274- } else {
275- if err := client .UpdatePRBase (pr .Number , baseBranch ); err != nil {
276- cfg .Warningf ("failed to update base branch for PR %s: %v" ,
277- cfg .PRLink (pr .Number , pr .URL ), err )
278- } else {
279- cfg .Successf ("Updated base branch for PR %s to %s" ,
280- cfg .PRLink (pr .Number , pr .URL ), baseBranch )
281- }
282- }
283- } else {
284- cfg .Printf ("PR %s for %s is up to date" , cfg .PRLink (pr .Number , pr .URL ), b .Branch )
157+ // Find or create PR, and fix base if needed
158+ baseBranch := s .ActiveBaseBranch (b .Branch )
159+ if err := ensurePR (cfg , client , s , i , baseBranch , opts ); err != nil {
160+ if errors .Is (err , errInterrupt ) {
161+ printInterrupt (cfg )
162+ return ErrSilent
285163 }
164+ // Non-fatal — continue with remaining branches
286165 }
287166 }
288167
289168 // Create or update the stack on GitHub
290169 if stacksAvailable {
291170 syncStack (cfg , client , s )
292- // Clear modify state after successful stack sync
293171 clearPendingModifyState (cfg , gitDir )
294172 }
295173
@@ -305,6 +183,91 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
305183 return nil
306184}
307185
186+ // ensurePR finds or creates a PR for the branch at index i, and updates
187+ // its base branch if needed. This is the single place where PR state is
188+ // reconciled during submit.
189+ func ensurePR (cfg * config.Config , client github.ClientOps , s * stack.Stack , i int , baseBranch string , opts * submitOptions ) error {
190+ b := s .Branches [i ]
191+
192+ pr , err := client .FindPRForBranch (b .Branch )
193+ if err != nil {
194+ cfg .Warningf ("failed to check PR for %s: %v" , b .Branch , err )
195+ return nil
196+ }
197+
198+ if pr == nil {
199+ return createPR (cfg , client , s , i , baseBranch , opts )
200+ }
201+
202+ // PR exists — record it and fix base if needed.
203+ if s .Branches [i ].PullRequest == nil {
204+ s .Branches [i ].PullRequest = & stack.PullRequestRef {
205+ Number : pr .Number ,
206+ ID : pr .ID ,
207+ URL : pr .URL ,
208+ }
209+ }
210+
211+ if pr .BaseRefName != baseBranch {
212+ if s .ID != "" {
213+ // Stack API owns base relationships — can't update directly.
214+ cfg .Warningf ("PR %s has base %q (expected %q) but cannot update while stacked" ,
215+ cfg .PRLink (pr .Number , pr .URL ), pr .BaseRefName , baseBranch )
216+ } else {
217+ if err := client .UpdatePRBase (pr .Number , baseBranch ); err != nil {
218+ cfg .Warningf ("failed to update base branch for PR %s: %v" ,
219+ cfg .PRLink (pr .Number , pr .URL ), err )
220+ } else {
221+ cfg .Successf ("Updated base branch for PR %s to %s" ,
222+ cfg .PRLink (pr .Number , pr .URL ), baseBranch )
223+ }
224+ }
225+ } else {
226+ cfg .Printf ("PR %s for %s is up to date" , cfg .PRLink (pr .Number , pr .URL ), b .Branch )
227+ }
228+
229+ return nil
230+ }
231+
232+ // createPR creates a new PR for the branch at index i.
233+ func createPR (cfg * config.Config , client github.ClientOps , s * stack.Stack , i int , baseBranch string , opts * submitOptions ) error {
234+ b := s .Branches [i ]
235+
236+ title , commitBody := defaultPRTitleBody (baseBranch , b .Branch )
237+ originalTitle := title
238+ if ! opts .auto && cfg .IsInteractive () {
239+ p := prompter .New (cfg .In , cfg .Out , cfg .Err )
240+ input , err := p .Input (fmt .Sprintf ("Title for PR (branch %s):" , b .Branch ), title )
241+ if err != nil {
242+ if isInterruptError (err ) {
243+ return errInterrupt
244+ }
245+ // Non-interrupt error: keep the auto-generated title.
246+ } else if input != "" {
247+ title = input
248+ }
249+ }
250+
251+ prBody := commitBody
252+ if title != originalTitle && commitBody != "" {
253+ prBody = originalTitle + "\n \n " + commitBody
254+ }
255+ body := generatePRBody (prBody )
256+
257+ newPR , createErr := client .CreatePR (baseBranch , b .Branch , title , body , opts .draft )
258+ if createErr != nil {
259+ cfg .Warningf ("failed to create PR for %s: %v" , b .Branch , createErr )
260+ return nil
261+ }
262+ cfg .Successf ("Created PR %s for %s" , cfg .PRLink (newPR .Number , newPR .URL ), b .Branch )
263+ s .Branches [i ].PullRequest = & stack.PullRequestRef {
264+ Number : newPR .Number ,
265+ ID : newPR .ID ,
266+ URL : newPR .URL ,
267+ }
268+ return nil
269+ }
270+
308271// defaultPRTitleBody generates a PR title and body from the branch's commits.
309272// If there is exactly one commit, use its subject as the title and its body
310273// (if any) as the PR body. Otherwise, humanize the branch name for the title.
0 commit comments