@@ -1019,3 +1019,270 @@ func TestSync_MergedBranchDeletedFromRemote(t *testing.T) {
10191019 assert .Equal (t , "main" , rebaseOntoCalls [0 ].newBase )
10201020 assert .Equal (t , "b1-stored-head-sha" , rebaseOntoCalls [0 ].oldBase )
10211021}
1022+
1023+ // TestSync_Prune_DeletesMergedBranches verifies that --prune deletes local
1024+ // branches for merged PRs while keeping them in the stack metadata.
1025+ func TestSync_Prune_DeletesMergedBranches (t * testing.T ) {
1026+ s := stack.Stack {
1027+ Trunk : stack.BranchRef {Branch : "main" },
1028+ Branches : []stack.BranchRef {
1029+ {Branch : "b1" , PullRequest : & stack.PullRequestRef {Number : 1 , Merged : true }},
1030+ {Branch : "b2" },
1031+ },
1032+ }
1033+
1034+ tmpDir := t .TempDir ()
1035+ writeStackFile (t , tmpDir , s )
1036+
1037+ var deletedBranches []string
1038+
1039+ mock := newSyncMock (tmpDir , "b2" )
1040+ mock .BranchExistsFn = func (name string ) bool { return true }
1041+ mock .DeleteBranchFn = func (name string , force bool ) error {
1042+ deletedBranches = append (deletedBranches , name )
1043+ assert .True (t , force , "should force-delete merged branch" )
1044+ return nil
1045+ }
1046+
1047+ restore := git .SetOps (mock )
1048+ defer restore ()
1049+
1050+ cfg , _ , errR := config .NewTestConfig ()
1051+ cmd := SyncCmd (cfg )
1052+ cmd .SetArgs ([]string {"--prune" })
1053+ cmd .SetOut (io .Discard )
1054+ cmd .SetErr (io .Discard )
1055+ err := cmd .Execute ()
1056+
1057+ cfg .Err .Close ()
1058+ errOut , _ := io .ReadAll (errR )
1059+ output := string (errOut )
1060+
1061+ assert .NoError (t , err )
1062+ assert .Equal (t , []string {"b1" }, deletedBranches )
1063+ assert .Contains (t , output , "Pruned b1 (merged)" )
1064+ assert .Contains (t , output , "Pruned 1 merged branch" )
1065+ }
1066+
1067+ // TestSync_Prune_SkipsNonExistentBranches verifies that --prune does not
1068+ // attempt to delete branches that have already been removed locally.
1069+ func TestSync_Prune_SkipsNonExistentBranches (t * testing.T ) {
1070+ s := stack.Stack {
1071+ Trunk : stack.BranchRef {Branch : "main" },
1072+ Branches : []stack.BranchRef {
1073+ {Branch : "b1" , Head : "sha-b1" , PullRequest : & stack.PullRequestRef {Number : 1 , Merged : true }},
1074+ {Branch : "b2" },
1075+ },
1076+ }
1077+
1078+ tmpDir := t .TempDir ()
1079+ writeStackFile (t , tmpDir , s )
1080+
1081+ mock := newSyncMock (tmpDir , "b2" )
1082+ mock .BranchExistsFn = func (name string ) bool {
1083+ return name != "b1" // b1 already deleted
1084+ }
1085+ mock .DeleteBranchFn = func (string , bool ) error {
1086+ t .Fatal ("DeleteBranch should not be called for non-existent branches" )
1087+ return nil
1088+ }
1089+
1090+ restore := git .SetOps (mock )
1091+ defer restore ()
1092+
1093+ cfg , _ , errR := config .NewTestConfig ()
1094+ cmd := SyncCmd (cfg )
1095+ cmd .SetArgs ([]string {"--prune" })
1096+ cmd .SetOut (io .Discard )
1097+ cmd .SetErr (io .Discard )
1098+ err := cmd .Execute ()
1099+
1100+ cfg .Err .Close ()
1101+ errOut , _ := io .ReadAll (errR )
1102+ output := string (errOut )
1103+
1104+ assert .NoError (t , err )
1105+ assert .Contains (t , output , "No merged branches to prune" )
1106+ }
1107+
1108+ // TestSync_Prune_SwitchesToLowestUnmergedBranch verifies that when the user is
1109+ // on a merged branch being pruned, checkout moves to the lowest active branch.
1110+ func TestSync_Prune_SwitchesToLowestUnmergedBranch (t * testing.T ) {
1111+ s := stack.Stack {
1112+ Trunk : stack.BranchRef {Branch : "main" },
1113+ Branches : []stack.BranchRef {
1114+ {Branch : "b1" , PullRequest : & stack.PullRequestRef {Number : 1 , Merged : true }},
1115+ {Branch : "b2" },
1116+ {Branch : "b3" },
1117+ },
1118+ }
1119+
1120+ tmpDir := t .TempDir ()
1121+ writeStackFile (t , tmpDir , s )
1122+
1123+ var deletedBranches []string
1124+ var checkoutTarget string
1125+
1126+ mock := newSyncMock (tmpDir , "b1" ) // currently on merged branch
1127+ mock .BranchExistsFn = func (name string ) bool { return true }
1128+ mock .CheckoutBranchFn = func (name string ) error {
1129+ checkoutTarget = name
1130+ return nil
1131+ }
1132+ mock .DeleteBranchFn = func (name string , force bool ) error {
1133+ deletedBranches = append (deletedBranches , name )
1134+ return nil
1135+ }
1136+
1137+ restore := git .SetOps (mock )
1138+ defer restore ()
1139+
1140+ cfg , _ , errR := config .NewTestConfig ()
1141+ cmd := SyncCmd (cfg )
1142+ cmd .SetArgs ([]string {"--prune" })
1143+ cmd .SetOut (io .Discard )
1144+ cmd .SetErr (io .Discard )
1145+ err := cmd .Execute ()
1146+
1147+ cfg .Err .Close ()
1148+ errOut , _ := io .ReadAll (errR )
1149+ output := string (errOut )
1150+
1151+ assert .NoError (t , err )
1152+ assert .Equal (t , []string {"b1" }, deletedBranches )
1153+ // Should have switched to b2 (first active branch), not trunk
1154+ assert .Equal (t , "b2" , checkoutTarget )
1155+ assert .Contains (t , output , "Pruned b1 (merged)" )
1156+ }
1157+
1158+ // TestSync_Prune_SwitchesToTrunkWhenAllMerged verifies that when all branches
1159+ // are merged, checkout moves to the trunk.
1160+ func TestSync_Prune_SwitchesToTrunkWhenAllMerged (t * testing.T ) {
1161+ s := stack.Stack {
1162+ Trunk : stack.BranchRef {Branch : "main" },
1163+ Branches : []stack.BranchRef {
1164+ {Branch : "b1" , PullRequest : & stack.PullRequestRef {Number : 1 , Merged : true }},
1165+ {Branch : "b2" , PullRequest : & stack.PullRequestRef {Number : 2 , Merged : true }},
1166+ },
1167+ }
1168+
1169+ tmpDir := t .TempDir ()
1170+ writeStackFile (t , tmpDir , s )
1171+
1172+ var deletedBranches []string
1173+ var checkoutTarget string
1174+
1175+ mock := newSyncMock (tmpDir , "b1" ) // currently on merged branch
1176+ mock .BranchExistsFn = func (name string ) bool { return true }
1177+ mock .CheckoutBranchFn = func (name string ) error {
1178+ checkoutTarget = name
1179+ return nil
1180+ }
1181+ mock .DeleteBranchFn = func (name string , force bool ) error {
1182+ deletedBranches = append (deletedBranches , name )
1183+ return nil
1184+ }
1185+
1186+ restore := git .SetOps (mock )
1187+ defer restore ()
1188+
1189+ cfg , _ , errR := config .NewTestConfig ()
1190+ cmd := SyncCmd (cfg )
1191+ cmd .SetArgs ([]string {"--prune" })
1192+ cmd .SetOut (io .Discard )
1193+ cmd .SetErr (io .Discard )
1194+ err := cmd .Execute ()
1195+
1196+ cfg .Err .Close ()
1197+ errOut , _ := io .ReadAll (errR )
1198+ output := string (errOut )
1199+
1200+ assert .NoError (t , err )
1201+ assert .Equal (t , []string {"b1" , "b2" }, deletedBranches )
1202+ // Should have switched to trunk since all branches are merged
1203+ assert .Equal (t , "main" , checkoutTarget )
1204+ assert .Contains (t , output , "Pruned 2 merged branches" )
1205+ }
1206+
1207+ // TestSync_NoPrune_DoesNotDeleteBranches verifies that without --prune,
1208+ // merged branches are not deleted (default behavior is unchanged).
1209+ func TestSync_NoPrune_DoesNotDeleteBranches (t * testing.T ) {
1210+ s := stack.Stack {
1211+ Trunk : stack.BranchRef {Branch : "main" },
1212+ Branches : []stack.BranchRef {
1213+ {Branch : "b1" , PullRequest : & stack.PullRequestRef {Number : 1 , Merged : true }},
1214+ {Branch : "b2" },
1215+ },
1216+ }
1217+
1218+ tmpDir := t .TempDir ()
1219+ writeStackFile (t , tmpDir , s )
1220+
1221+ mock := newSyncMock (tmpDir , "b2" )
1222+ mock .BranchExistsFn = func (name string ) bool { return true }
1223+ mock .DeleteBranchFn = func (string , bool ) error {
1224+ t .Fatal ("DeleteBranch should not be called without --prune" )
1225+ return nil
1226+ }
1227+
1228+ restore := git .SetOps (mock )
1229+ defer restore ()
1230+
1231+ cfg , _ , _ := config .NewTestConfig ()
1232+ cmd := SyncCmd (cfg )
1233+ // No --prune flag
1234+ cmd .SetOut (io .Discard )
1235+ cmd .SetErr (io .Discard )
1236+ err := cmd .Execute ()
1237+
1238+ assert .NoError (t , err )
1239+ }
1240+
1241+ // TestSync_Prune_DeleteFailureContinues verifies that a failed branch deletion
1242+ // logs a warning and does not abort the sync.
1243+ func TestSync_Prune_DeleteFailureContinues (t * testing.T ) {
1244+ s := stack.Stack {
1245+ Trunk : stack.BranchRef {Branch : "main" },
1246+ Branches : []stack.BranchRef {
1247+ {Branch : "b1" , PullRequest : & stack.PullRequestRef {Number : 1 , Merged : true }},
1248+ {Branch : "b2" , PullRequest : & stack.PullRequestRef {Number : 2 , Merged : true }},
1249+ {Branch : "b3" },
1250+ },
1251+ }
1252+
1253+ tmpDir := t .TempDir ()
1254+ writeStackFile (t , tmpDir , s )
1255+
1256+ var deletedBranches []string
1257+
1258+ mock := newSyncMock (tmpDir , "b3" )
1259+ mock .BranchExistsFn = func (name string ) bool { return true }
1260+ mock .DeleteBranchFn = func (name string , force bool ) error {
1261+ if name == "b1" {
1262+ return fmt .Errorf ("permission denied" )
1263+ }
1264+ deletedBranches = append (deletedBranches , name )
1265+ return nil
1266+ }
1267+
1268+ restore := git .SetOps (mock )
1269+ defer restore ()
1270+
1271+ cfg , _ , errR := config .NewTestConfig ()
1272+ cmd := SyncCmd (cfg )
1273+ cmd .SetArgs ([]string {"--prune" })
1274+ cmd .SetOut (io .Discard )
1275+ cmd .SetErr (io .Discard )
1276+ err := cmd .Execute ()
1277+
1278+ cfg .Err .Close ()
1279+ errOut , _ := io .ReadAll (errR )
1280+ output := string (errOut )
1281+
1282+ assert .NoError (t , err )
1283+ // b1 failed, b2 succeeded
1284+ assert .Equal (t , []string {"b2" }, deletedBranches )
1285+ assert .Contains (t , output , "Failed to delete b1" )
1286+ assert .Contains (t , output , "Pruned b2 (merged)" )
1287+ assert .Contains (t , output , "Pruned 1 merged branch" )
1288+ }
0 commit comments