Skip to content

Commit f20a0a3

Browse files
committed
refactor submit for regular and pending modifications
1 parent 9025606 commit f20a0a3

2 files changed

Lines changed: 108 additions & 144 deletions

File tree

cmd/submit.go

Lines changed: 104 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

cmd/submit_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,11 @@ func TestSubmit_CreatesPRsAndStack(t *testing.T) {
123123

124124
assert.NoError(t, err)
125125

126-
// Branches should be pushed
127-
require.Len(t, pushCalls, 1)
126+
// Branches should be pushed (sequentially, one per branch)
127+
require.Len(t, pushCalls, 2)
128128
assert.Equal(t, "origin", pushCalls[0].remote)
129-
assert.Equal(t, []string{"b1", "b2"}, pushCalls[0].branches)
129+
assert.Equal(t, []string{"b1"}, pushCalls[0].branches)
130+
assert.Equal(t, []string{"b2"}, pushCalls[1].branches)
130131

131132
// PRs should be created
132133
assert.Equal(t, []string{"b1", "b2"}, createdPRs)

0 commit comments

Comments
 (0)