Skip to content

Commit 677a702

Browse files
committed
prune merged branches
1 parent 2310ca1 commit 677a702

2 files changed

Lines changed: 329 additions & 2 deletions

File tree

cmd/sync.go

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
type syncOptions struct {
1616
remote string
17+
prune bool
1718
}
1819

1920
func SyncCmd(cfg *config.Config) *cobra.Command {
@@ -34,13 +35,19 @@ This command performs a safe, non-interactive synchronization:
3435
3536
If a rebase conflict is detected, all branches are restored to their
3637
original state and you are advised to run "gh stack rebase" to resolve
37-
conflicts interactively.`,
38+
conflicts interactively.
39+
40+
Use --prune to delete local branches for merged PRs. Stack metadata is
41+
preserved so that rebase and display logic continue to work correctly.
42+
If you are on a branch that would be pruned, your checkout is moved to
43+
the nearest active branch or the trunk.`,
3844
RunE: func(cmd *cobra.Command, args []string) error {
3945
return runSync(cfg, opts)
4046
},
4147
}
4248

4349
cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from and push to (defaults to auto-detected remote)")
50+
cmd.Flags().BoolVar(&opts.prune, "prune", false, "Delete local branches for merged PRs")
4451

4552
return cmd
4653
}
@@ -341,7 +348,60 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
341348
cfg.Printf("Merged: %s", strings.Join(names, ", "))
342349
}
343350

344-
// --- Step 6: Update base SHAs and save ---
351+
// --- Step 6: Prune merged branches (optional) ---
352+
if opts.prune {
353+
merged := s.MergedBranches()
354+
var prunable []string
355+
for _, b := range merged {
356+
if git.BranchExists(b.Branch) {
357+
prunable = append(prunable, b.Branch)
358+
}
359+
}
360+
361+
if len(prunable) > 0 {
362+
// If the current branch is being pruned, switch away first.
363+
needsSwitch := false
364+
for _, name := range prunable {
365+
if name == currentBranch {
366+
needsSwitch = true
367+
break
368+
}
369+
}
370+
if needsSwitch {
371+
switchTarget := trunk
372+
for _, b := range s.Branches {
373+
if !b.IsSkipped() {
374+
switchTarget = b.Branch
375+
break
376+
}
377+
}
378+
if err := git.CheckoutBranch(switchTarget); err != nil {
379+
cfg.Warningf("Failed to switch from %s to %s: %v", currentBranch, switchTarget, err)
380+
} else {
381+
currentBranch = switchTarget
382+
}
383+
}
384+
385+
cfg.Printf("")
386+
pruned := 0
387+
for _, name := range prunable {
388+
if err := git.DeleteBranch(name, true); err != nil {
389+
cfg.Warningf("Failed to delete %s: %v", name, err)
390+
} else {
391+
cfg.Successf("Pruned %s (merged)", name)
392+
pruned++
393+
}
394+
}
395+
if pruned > 0 {
396+
cfg.Successf("Pruned %d merged %s", pruned, plural(pruned, "branch", "branches"))
397+
}
398+
} else {
399+
cfg.Printf("")
400+
cfg.Printf("No merged branches to prune")
401+
}
402+
}
403+
404+
// --- Step 7: Update base SHAs and save ---
345405
updateBaseSHAs(s)
346406

347407
if err := stack.Save(gitDir, sf); err != nil {

cmd/sync_test.go

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)