@@ -3,6 +3,7 @@ package cmd
33import (
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.
686717func 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).
791825func 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