Skip to content

Commit 9025606

Browse files
committed
tests for modify tui, apply modifications, submit modifications
1 parent d9a6dde commit 9025606

3 files changed

Lines changed: 2112 additions & 0 deletions

File tree

cmd/submit_test.go

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
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

Comments
 (0)