11package cmd
22
33import (
4+ "encoding/json"
45 "fmt"
56 "io"
67 "net/url"
@@ -11,6 +12,7 @@ import (
1112 "github.com/github/gh-stack/internal/config"
1213 "github.com/github/gh-stack/internal/git"
1314 "github.com/github/gh-stack/internal/github"
15+ "github.com/github/gh-stack/internal/modify"
1416 "github.com/github/gh-stack/internal/stack"
1517 "github.com/stretchr/testify/assert"
1618 "github.com/stretchr/testify/require"
@@ -1073,3 +1075,297 @@ func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) {
10731075 // after PR creation), so expect exactly 2 ListStacks calls.
10741076 assert .Equal (t , 2 , listStacksCallCount , "ListStacks should only be called by syncStackPRs, not by the preflight check" )
10751077}
1078+
1079+ // --- Modify + Submit integration tests ---
1080+
1081+ func saveModifyState (t * testing.T , gitDir string , state * modify.StateFile ) {
1082+ t .Helper ()
1083+ require .NoError (t , modify .SaveState (gitDir , state ))
1084+ }
1085+
1086+ func newPendingSubmitState (priorStackID string ) * modify.StateFile {
1087+ return & modify.StateFile {
1088+ SchemaVersion : 1 ,
1089+ Phase : "pending_submit" ,
1090+ PriorRemoteStackID : priorStackID ,
1091+ Snapshot : modify.Snapshot {StackMetadata : json .RawMessage (`{}` )},
1092+ }
1093+ }
1094+
1095+ func TestHandlePendingModify_DeletesOldStack (t * testing.T ) {
1096+ gitDir := t .TempDir ()
1097+
1098+ saveModifyState (t , gitDir , newPendingSubmitState ("stack-123" ))
1099+
1100+ s := & stack.Stack {ID : "stack-123" , Trunk : stack.BranchRef {Branch : "main" }}
1101+
1102+ var deletedStackID string
1103+ client := & github.MockClient {
1104+ DeleteStackFn : func (id string ) error {
1105+ deletedStackID = id
1106+ return nil
1107+ },
1108+ }
1109+
1110+ cfg , _ , _ := config .NewTestConfig ()
1111+ defer cfg .Out .Close ()
1112+ defer cfg .Err .Close ()
1113+
1114+ err := handlePendingModify (cfg , client , s , gitDir )
1115+ require .NoError (t , err )
1116+ assert .Equal (t , "stack-123" , deletedStackID )
1117+ assert .Equal (t , "" , s .ID )
1118+ }
1119+
1120+ func TestHandlePendingModify_NoStateFile (t * testing.T ) {
1121+ gitDir := t .TempDir ()
1122+ // No state file on disk.
1123+
1124+ s := & stack.Stack {ID : "stack-123" , Trunk : stack.BranchRef {Branch : "main" }}
1125+
1126+ deleteCalled := false
1127+ client := & github.MockClient {
1128+ DeleteStackFn : func (id string ) error {
1129+ deleteCalled = true
1130+ return nil
1131+ },
1132+ }
1133+
1134+ cfg , _ , _ := config .NewTestConfig ()
1135+ defer cfg .Out .Close ()
1136+ defer cfg .Err .Close ()
1137+
1138+ err := handlePendingModify (cfg , client , s , gitDir )
1139+ assert .NoError (t , err )
1140+ assert .False (t , deleteCalled , "DeleteStack should not be called when no state file exists" )
1141+ assert .Equal (t , "stack-123" , s .ID , "stack ID should remain unchanged" )
1142+ }
1143+
1144+ func TestHandlePendingModify_WrongPhase (t * testing.T ) {
1145+ gitDir := t .TempDir ()
1146+
1147+ state := & modify.StateFile {
1148+ SchemaVersion : 1 ,
1149+ Phase : "conflict" ,
1150+ Snapshot : modify.Snapshot {StackMetadata : json .RawMessage (`{}` )},
1151+ }
1152+ saveModifyState (t , gitDir , state )
1153+
1154+ s := & stack.Stack {ID : "stack-99" , Trunk : stack.BranchRef {Branch : "main" }}
1155+
1156+ deleteCalled := false
1157+ client := & github.MockClient {
1158+ DeleteStackFn : func (id string ) error {
1159+ deleteCalled = true
1160+ return nil
1161+ },
1162+ }
1163+
1164+ cfg , _ , _ := config .NewTestConfig ()
1165+ defer cfg .Out .Close ()
1166+ defer cfg .Err .Close ()
1167+
1168+ err := handlePendingModify (cfg , client , s , gitDir )
1169+ assert .NoError (t , err )
1170+ assert .False (t , deleteCalled , "DeleteStack should not be called for non-pending_submit phase" )
1171+ assert .Equal (t , "stack-99" , s .ID , "stack ID should remain unchanged" )
1172+ }
1173+
1174+ func TestHandlePendingModify_DeleteFails (t * testing.T ) {
1175+ gitDir := t .TempDir ()
1176+
1177+ saveModifyState (t , gitDir , newPendingSubmitState ("stack-456" ))
1178+
1179+ s := & stack.Stack {ID : "stack-456" , Trunk : stack.BranchRef {Branch : "main" }}
1180+
1181+ client := & github.MockClient {
1182+ DeleteStackFn : func (id string ) error {
1183+ return fmt .Errorf ("server error" )
1184+ },
1185+ }
1186+
1187+ cfg , _ , _ := config .NewTestConfig ()
1188+ defer cfg .Out .Close ()
1189+ defer cfg .Err .Close ()
1190+
1191+ err := handlePendingModify (cfg , client , s , gitDir )
1192+ assert .Error (t , err )
1193+ assert .Equal (t , "stack-456" , s .ID , "stack ID should NOT be cleared on delete failure" )
1194+ }
1195+
1196+ func TestHandlePendingModify_Delete404 (t * testing.T ) {
1197+ gitDir := t .TempDir ()
1198+
1199+ saveModifyState (t , gitDir , newPendingSubmitState ("stack-gone" ))
1200+
1201+ s := & stack.Stack {ID : "stack-gone" , Trunk : stack.BranchRef {Branch : "main" }}
1202+
1203+ client := & github.MockClient {
1204+ DeleteStackFn : func (id string ) error {
1205+ return & api.HTTPError {
1206+ StatusCode : 404 ,
1207+ Message : "Not Found" ,
1208+ RequestURL : & url.URL {Path : "/repos/o/r/cli_internal/pulls/stacks/stack-gone" },
1209+ }
1210+ },
1211+ }
1212+
1213+ cfg , _ , _ := config .NewTestConfig ()
1214+ defer cfg .Out .Close ()
1215+ defer cfg .Err .Close ()
1216+
1217+ err := handlePendingModify (cfg , client , s , gitDir )
1218+ require .NoError (t , err , "404 should be treated as success (stack already deleted)" )
1219+ assert .Equal (t , "" , s .ID , "stack ID should be cleared after 404" )
1220+ }
1221+
1222+ func TestClearPendingModifyState_ClearsFile (t * testing.T ) {
1223+ gitDir := t .TempDir ()
1224+
1225+ saveModifyState (t , gitDir , newPendingSubmitState ("stack-789" ))
1226+ require .True (t , modify .StateExists (gitDir ), "precondition: state file should exist" )
1227+
1228+ cfg , _ , _ := config .NewTestConfig ()
1229+ defer cfg .Out .Close ()
1230+ defer cfg .Err .Close ()
1231+
1232+ clearPendingModifyState (cfg , gitDir )
1233+ assert .False (t , modify .StateExists (gitDir ), "state file should be removed" )
1234+ }
1235+
1236+ func TestClearPendingModifyState_NoFile (t * testing.T ) {
1237+ gitDir := t .TempDir ()
1238+ // No state file on disk.
1239+
1240+ cfg , _ , _ := config .NewTestConfig ()
1241+ defer cfg .Out .Close ()
1242+ defer cfg .Err .Close ()
1243+
1244+ // Should not panic or error.
1245+ clearPendingModifyState (cfg , gitDir )
1246+ assert .False (t , modify .StateExists (gitDir ))
1247+ }
1248+
1249+ func TestSubmit_WithPendingModify_SequentialPush (t * testing.T ) {
1250+ s := stack.Stack {
1251+ ID : "old-stack-42" ,
1252+ Trunk : stack.BranchRef {Branch : "main" },
1253+ Branches : []stack.BranchRef {
1254+ {Branch : "b1" , PullRequest : & stack.PullRequestRef {Number : 10 }},
1255+ {Branch : "b2" , PullRequest : & stack.PullRequestRef {Number : 11 }},
1256+ {Branch : "b3" , PullRequest : & stack.PullRequestRef {Number : 12 }},
1257+ },
1258+ }
1259+
1260+ tmpDir := t .TempDir ()
1261+ writeStackFile (t , tmpDir , s )
1262+ saveModifyState (t , tmpDir , newPendingSubmitState ("old-stack-42" ))
1263+
1264+ // Track call ordering
1265+ var callOrder []string
1266+ var pushCalls []pushCall
1267+
1268+ mock := newSubmitMock (tmpDir , "b1" )
1269+ mock .PushFn = func (remote string , branches []string , force , atomic bool ) error {
1270+ pushCalls = append (pushCalls , pushCall {remote , branches , force , atomic })
1271+ callOrder = append (callOrder , fmt .Sprintf ("push:%s" , branches [0 ]))
1272+ return nil
1273+ }
1274+
1275+ restore := git .SetOps (mock )
1276+ defer restore ()
1277+
1278+ var deletedStackID string
1279+ var createdStackPRs []int
1280+
1281+ cfg , _ , errR := config .NewTestConfig ()
1282+ cfg .GitHubClientOverride = & github.MockClient {
1283+ DeleteStackFn : func (id string ) error {
1284+ deletedStackID = id
1285+ callOrder = append (callOrder , "delete:" + id )
1286+ return nil
1287+ },
1288+ FindPRForBranchFn : func (branch string ) (* github.PullRequest , error ) {
1289+ switch branch {
1290+ case "b1" :
1291+ return & github.PullRequest {
1292+ Number : 10 , ID : "PR_10" ,
1293+ URL : "https://github.com/owner/repo/pull/10" ,
1294+ BaseRefName : "main" , HeadRefName : "b1" ,
1295+ State : "OPEN" ,
1296+ }, nil
1297+ case "b2" :
1298+ return & github.PullRequest {
1299+ Number : 11 , ID : "PR_11" ,
1300+ URL : "https://github.com/owner/repo/pull/11" ,
1301+ BaseRefName : "b1" , HeadRefName : "b2" ,
1302+ State : "OPEN" ,
1303+ }, nil
1304+ case "b3" :
1305+ return & github.PullRequest {
1306+ Number : 12 , ID : "PR_12" ,
1307+ URL : "https://github.com/owner/repo/pull/12" ,
1308+ BaseRefName : "b2" , HeadRefName : "b3" ,
1309+ State : "OPEN" ,
1310+ }, nil
1311+ }
1312+ return nil , nil
1313+ },
1314+ CreateStackFn : func (prNumbers []int ) (int , error ) {
1315+ createdStackPRs = prNumbers
1316+ callOrder = append (callOrder , "create_stack" )
1317+ return 99 , nil
1318+ },
1319+ ListStacksFn : func () ([]github.RemoteStack , error ) {
1320+ return []github.RemoteStack {}, nil
1321+ },
1322+ }
1323+
1324+ cmd := SubmitCmd (cfg )
1325+ cmd .SetArgs ([]string {"--auto" })
1326+ cmd .SetOut (io .Discard )
1327+ cmd .SetErr (io .Discard )
1328+ err := cmd .Execute ()
1329+
1330+ cfg .Err .Close ()
1331+ _ , _ = io .ReadAll (errR )
1332+
1333+ assert .NoError (t , err )
1334+
1335+ // DeleteStack called with old stack ID
1336+ assert .Equal (t , "old-stack-42" , deletedStackID )
1337+
1338+ // Push called per-branch (3 separate calls, not 1 atomic call)
1339+ require .Len (t , pushCalls , 3 , "should push each branch individually" )
1340+ assert .Equal (t , []string {"b1" }, pushCalls [0 ].branches )
1341+ assert .Equal (t , []string {"b2" }, pushCalls [1 ].branches )
1342+ assert .Equal (t , []string {"b3" }, pushCalls [2 ].branches )
1343+ for _ , pc := range pushCalls {
1344+ assert .False (t , pc .atomic , "sequential push should not use atomic mode" )
1345+ }
1346+
1347+ // CreateStack called with all 3 PRs
1348+ assert .Equal (t , []int {10 , 11 , 12 }, createdStackPRs )
1349+
1350+ // Verify ordering: delete before push, push before create_stack
1351+ assert .True (t , len (callOrder ) >= 5 , "expected at least 5 calls, got %d: %v" , len (callOrder ), callOrder )
1352+ deleteIdx := - 1
1353+ firstPushIdx := - 1
1354+ createIdx := - 1
1355+ for i , c := range callOrder {
1356+ if c == "delete:old-stack-42" && deleteIdx == - 1 {
1357+ deleteIdx = i
1358+ }
1359+ if c == "push:b1" && firstPushIdx == - 1 {
1360+ firstPushIdx = i
1361+ }
1362+ if c == "create_stack" && createIdx == - 1 {
1363+ createIdx = i
1364+ }
1365+ }
1366+ assert .Greater (t , firstPushIdx , deleteIdx , "delete should happen before push" )
1367+ assert .Greater (t , createIdx , firstPushIdx , "create_stack should happen after push" )
1368+
1369+ // State file should be cleared
1370+ assert .False (t , modify .StateExists (tmpDir ), "modify state file should be cleared after success" )
1371+ }
0 commit comments