Skip to content

Commit 71f515e

Browse files
committed
Handle partial unstacking after stack modification
1 parent e96b74e commit 71f515e

2 files changed

Lines changed: 274 additions & 98 deletions

File tree

‎cmd/submit.go‎

Lines changed: 109 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"errors"
55
"fmt"
6+
"slices"
67
"strconv"
78
"strings"
89

@@ -147,17 +148,6 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
147148
// Sync PR state to detect merged/queued PRs before pushing.
148149
prDetails := syncStackPRs(cfg, s)
149150

150-
// If the active branches now sit on top of a fully-merged base, they can no
151-
// longer extend the existing remote stack. Fork them into a fresh stack
152-
// rooted at the trunk and continue the submit with that new stack.
153-
if stacksAvailable {
154-
s, err = maybeForkFromMergedBase(cfg, client, sf, s, gitDir)
155-
if err != nil {
156-
cfg.Errorf("%s", err)
157-
return ErrSilent
158-
}
159-
}
160-
161151
// Resolve remote for pushing
162152
remote, err := pickRemote(cfg, currentBranch, opts.remote)
163153
if err != nil {
@@ -198,6 +188,18 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
198188
}
199189
}
200190

191+
// A pending modify owns its replacement identity, including on retries
192+
// after unstacking left only merged PRs in the old remote stack.
193+
if stacksAvailable && !pendingModify {
194+
s, err = maybeForkFromMergedBase(cfg, client, sf, s, gitDir)
195+
if err != nil {
196+
cfg.Errorf("%s", err)
197+
return ErrSilent
198+
}
199+
}
200+
activeBranches = activeBranchNames(s)
201+
queued = s.QueuedBranches()
202+
201203
// Best-effort fetch to update tracking refs (helps --force-with-lease
202204
// in shallow clones). Silently ignored if branches don't exist on the
203205
// remote yet.
@@ -283,7 +285,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
283285
return stackSaveError(cfg, err)
284286
}
285287
if pendingModify {
286-
if !allPRsSubmitted || (!stackSynced && len(s.Branches) != 1) {
288+
if !allPRsSubmitted || (!stackSynced && len(activeBranches) > 1) {
287289
cfg.Errorf("modify is not fully submitted; recovery state was retained")
288290
cfg.Printf("Finish submitting all remaining PRs, then retry `%s`", cfg.ColorCyan("gh stack submit"))
289291
return ErrSilent
@@ -565,7 +567,11 @@ func maybeForkFromMergedBase(cfg *config.Config, client github.ClientOps, sf *st
565567
return s, nil
566568
}
567569

568-
remotePRs := remoteStackPRs(client, s.ID)
570+
remote, err := remoteStackByID(client, s.ID)
571+
if err != nil || remote == nil {
572+
return s, nil
573+
}
574+
remotePRs := remote.PRNumbers()
569575
if len(remotePRs) == 0 {
570576
return s, nil
571577
}
@@ -651,19 +657,17 @@ func maybeForkFromMergedBase(cfg *config.Config, client github.ClientOps, sf *st
651657
return &sf.Stacks[len(sf.Stacks)-1], nil
652658
}
653659

654-
// remoteStackPRs returns the PR numbers that are officially part of the remote
655-
// stack identified by stackID, or nil if it can't be determined.
656-
func remoteStackPRs(client github.ClientOps, stackID string) []int {
660+
func remoteStackByID(client github.ClientOps, stackID string) (*github.RemoteStack, error) {
657661
stacks, err := client.ListStacks()
658662
if err != nil {
659-
return nil
663+
return nil, err
660664
}
661-
for _, rs := range stacks {
662-
if strconv.Itoa(rs.ID) == stackID {
663-
return rs.PRNumbers()
665+
for i := range stacks {
666+
if strconv.Itoa(stacks[i].ID) == stackID {
667+
return &stacks[i], nil
664668
}
665669
}
666-
return nil
670+
return nil, nil
667671
}
668672

669673
// mergedPRNumbers returns the set of PR numbers whose local branch is marked
@@ -679,9 +683,36 @@ func mergedPRNumbers(s *stack.Stack) map[int]bool {
679683
return merged
680684
}
681685

686+
// prepareStackRecreation recognizes a merged-only remote remnant and records
687+
// fresh merge state without removing any local branches.
688+
func prepareStackRecreation(s *stack.Stack, remaining *github.RemoteStack) bool {
689+
if remaining == nil || len(remaining.PRNumbers()) == 0 {
690+
return false
691+
}
692+
merged := mergedPRNumbers(s)
693+
for _, pr := range remaining.PRDetails {
694+
if pr.IsMerged() {
695+
merged[pr.Number] = true
696+
}
697+
}
698+
for _, number := range remaining.PRNumbers() {
699+
if !merged[number] {
700+
return false
701+
}
702+
}
703+
for i := range s.Branches {
704+
b := &s.Branches[i]
705+
if b.PullRequest != nil && merged[b.PullRequest.Number] {
706+
b.PullRequest.Merged = true
707+
b.Queued = false
708+
}
709+
}
710+
return true
711+
}
712+
682713
// handlePendingModify handles the stack recreation after a modify operation.
683-
// It deletes the old remote stack and clears s.ID so syncStack creates a new
684-
// one. The bool identifies a matching pending modification. Its journal is
714+
// It unstacks the old PRs and clears s.ID so syncStack creates a replacement.
715+
// The bool identifies a matching pending modification. Its journal is
685716
// cleared only after the caller verifies all required submission outcomes.
686717
func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.Stack, gitDir string) (bool, error) {
687718
state, err := modify.LoadState(gitDir)
@@ -719,17 +750,19 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S
719750
}
720751
}
721752

722-
// Delete the old remote stack
753+
// Release the old stack's unmerged PRs before submitting the replacement.
723754
if state.PriorRemoteStackID != "" {
724-
number, found, lookupErr := stackNumberByID(client, state.PriorRemoteStackID)
755+
remote, lookupErr := remoteStackByID(client, state.PriorRemoteStackID)
725756
if lookupErr != nil {
726757
cfg.Warningf("Failed to look up existing stack: %v", lookupErr)
727758
cfg.Printf("Run `%s` again to retry", cfg.ColorCyan("gh stack submit"))
728759
return true, lookupErr
729760
}
730-
if !found {
761+
if remote == nil {
731762
cfg.Printf("Previous stack already deleted on GitHub")
732-
} else if _, dissolved, err := client.Unstack(number); err != nil {
763+
} else if prepareStackRecreation(s, remote) {
764+
cfg.Infof("Only merged pull requests remain in the previous stack on GitHub")
765+
} else if remaining, dissolved, err := client.Unstack(remote.Number); err != nil {
733766
var httpErr *api.HTTPError
734767
if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
735768
cfg.Printf("Previous stack already deleted on GitHub")
@@ -738,11 +771,14 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S
738771
cfg.Printf("Run `%s` again to retry", cfg.ColorCyan("gh stack submit"))
739772
return true, err
740773
}
741-
} else if !dissolved {
742-
cfg.Errorf("the previous stack still has pull requests queued for merge or with auto-merge enabled; it cannot be recreated yet")
774+
} else if !dissolved && !prepareStackRecreation(s, remaining) {
775+
cfg.Errorf("Some pull requests still belong to the previous stack on GitHub; it cannot be recreated yet")
743776
return true, ErrConflict
744-
} else {
777+
} else if dissolved {
745778
cfg.Successf("Cleared existing stack on GitHub")
779+
} else {
780+
cfg.Successf("Unstacked pull requests from the previous stack on GitHub")
781+
cfg.Infof("Merged pull requests remain in the previous stack on GitHub")
746782
}
747783
}
748784
// Record branch identity before the replacement receives a different ID.
@@ -778,41 +814,52 @@ func clearPendingModifyState(cfg *config.Config, s *stack.Stack, gitDir string)
778814
return nil
779815
}
780816

781-
// syncStack creates or updates a stack on GitHub from the active PRs.
782-
// If the stack already exists (s.ID is set), it calls the PUT endpoint with
783-
// the full list of PRs to keep the remote stack in sync. If no stack exists
784-
// yet, it calls POST to create one.
817+
// syncStack creates or updates a stack on GitHub. Existing stacks keep their
818+
// merged members; new stacks exclude merged history from previous stacks.
785819
// This is a best-effort operation: failures are reported as warnings but do
786820
// not cause the submit command to fail (the PRs are already created).
787821
//
788822
// It returns true when the remote stack object reflects the local stack
789823
// (created, updated, or already in sync) and false otherwise (fewer than two
790824
// PRs, an unresolved divergence, stacked PRs unavailable, or an API failure).
791825
func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) bool {
792-
// Collect PR numbers in stack order (bottom to top), including merged PRs.
793-
// The API expects the full list — omitting merged PRs causes a
794-
// "Stack contents have changed" rejection.
795-
var prNumbers []int
826+
prCount := 0
796827
for _, b := range s.Branches {
797828
if b.PullRequest != nil {
798-
prNumbers = append(prNumbers, b.PullRequest.Number)
829+
prCount++
799830
}
800831
}
801832

802833
// The API requires at least 2 PRs to form a stack.
803-
if len(prNumbers) < 2 {
834+
if prCount < 2 {
804835
return false
805836
}
806837

807838
if s.ID != "" {
808-
return updateStack(cfg, client, s, prNumbers)
839+
return updateStack(cfg, client, s)
809840
}
810841

811842
// No locally tracked stack ID. The stack may already exist on GitHub
812843
// (created from the web UI or another clone) without being recorded
813844
// locally. Adopt it instead of blindly creating a new one, which the API
814845
// rejects because the PRs are already part of a stack.
815-
return reconcileUntrackedStack(cfg, client, s, prNumbers)
846+
return reconcileUntrackedStack(cfg, client, s, stackPRNumbers(s, nil))
847+
}
848+
849+
// stackPRNumbers includes merged PRs only when they belong to the remote stack
850+
// being updated, rather than to a previous stack retained after unstacking.
851+
func stackPRNumbers(s *stack.Stack, remote *github.RemoteStack) []int {
852+
var members []int
853+
if remote != nil {
854+
members = remote.PRNumbers()
855+
}
856+
var numbers []int
857+
for _, b := range s.Branches {
858+
if b.PullRequest != nil && (!b.IsMerged() || slices.Contains(members, b.PullRequest.Number)) {
859+
numbers = append(numbers, b.PullRequest.Number)
860+
}
861+
}
862+
return numbers
816863
}
817864

818865
// reconcileUntrackedStack reconciles a locally untracked stack (s.ID == "")
@@ -828,7 +875,7 @@ func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *sta
828875
if err != nil {
829876
// Couldn't inspect remote state — fall back to the create path, which
830877
// reports its own errors (handleCreate422 covers "already stacked").
831-
return createNewStack(cfg, client, s, prNumbers)
878+
return createNewStack(cfg, client, s)
832879
}
833880

834881
matched, err := findMatchingStack(stacks, prNumbers)
@@ -844,9 +891,10 @@ func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *sta
844891

845892
if matched == nil {
846893
// No existing stack contains any of our PRs — create a new one.
847-
return createNewStack(cfg, client, s, prNumbers)
894+
return createNewStack(cfg, client, s)
848895
}
849896

897+
prNumbers = stackPRNumbers(s, matched)
850898
// A remote stack already contains some of our PRs. Refuse to silently drop
851899
// any PRs it holds that we aren't tracking locally; let the user reconcile.
852900
if dropped := prsMissingFrom(matched.PRNumbers(), prNumbers); len(dropped) > 0 {
@@ -869,7 +917,7 @@ func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *sta
869917
}
870918

871919
cfg.Infof("Found the stack on GitHub — updating it to match your local stack")
872-
return updateStack(cfg, client, s, prNumbers)
920+
return updateStack(cfg, client, s)
873921
}
874922

875923
// prsMissingFrom returns the numbers in remote that do not appear in local,
@@ -895,13 +943,13 @@ func prsMissingFrom(remote, local []int) []int {
895943
// leaves the remote stack untouched. If the remote stack is gone (404) it
896944
// clears the local ID and re-creates it. Returns true when the remote stack
897945
// reflects the local stack (updated, already in sync, or recreated).
898-
func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool {
946+
func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) bool {
899947
number, err := ensureStackNumber(client, s)
900948
if err != nil || number == 0 {
901949
// Can't resolve the remote stack — treat as missing and (re)create.
902950
s.ID = ""
903951
s.Number = 0
904-
return createNewStack(cfg, client, s, prNumbers)
952+
return createNewStack(cfg, client, s)
905953
}
906954

907955
remote, err := client.GetStack(number)
@@ -910,13 +958,20 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr
910958
if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
911959
s.ID = ""
912960
s.Number = 0
913-
return createNewStack(cfg, client, s, prNumbers)
961+
return createNewStack(cfg, client, s)
914962
}
915963
cfg.Warningf("Failed to read stack on GitHub: %v", err)
916964
return false
917965
}
918966

967+
if prepareStackRecreation(s, remote) && len(s.ActiveBranches()) > 0 {
968+
s.ID = ""
969+
s.Number = 0
970+
return createNewStack(cfg, client, s)
971+
}
972+
919973
current := remote.PRNumbers()
974+
prNumbers := stackPRNumbers(s, remote)
920975
if slicesEqual(current, prNumbers) {
921976
s.ID = strconv.Itoa(remote.ID)
922977
s.Number = remote.Number
@@ -947,7 +1002,7 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr
9471002
// immediately try to re-create it.
9481003
s.ID = ""
9491004
s.Number = 0
950-
return createNewStack(cfg, client, s, prNumbers)
1005+
return createNewStack(cfg, client, s)
9511006
case 422:
9521007
// A merged branch whose ref has been deleted upstream breaks the
9531008
// stack's base→head chain, so the update is rejected. This is
@@ -977,7 +1032,11 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr
9771032
// createNewStack calls the POST endpoint to create a new stack, handling the
9781033
// three types of 422 errors the API may return.
9791034
// Returns true when the stack was created or is confirmed already in sync.
980-
func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool {
1035+
func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) bool {
1036+
prNumbers := stackPRNumbers(s, nil)
1037+
if len(prNumbers) < 2 {
1038+
return false
1039+
}
9811040
rs, err := client.CreateStack(prNumbers)
9821041
if err == nil {
9831042
s.ID = strconv.Itoa(rs.ID)

0 commit comments

Comments
 (0)