Skip to content

Commit 4a30bb6

Browse files
committed
interactively prompt for prune
1 parent 677a702 commit 4a30bb6

3 files changed

Lines changed: 212 additions & 2 deletions

File tree

cmd/sync.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"strings"
77

8+
"github.com/cli/go-gh/v2/pkg/prompter"
89
"github.com/github/gh-stack/internal/config"
910
"github.com/github/gh-stack/internal/git"
1011
"github.com/github/gh-stack/internal/modify"
@@ -349,7 +350,35 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
349350
}
350351

351352
// --- Step 6: Prune merged branches (optional) ---
352-
if opts.prune {
353+
doPrune := opts.prune
354+
if !doPrune {
355+
// --prune was not provided. If interactive, prompt.
356+
merged := s.MergedBranches()
357+
var prunableCount int
358+
for _, b := range merged {
359+
if git.BranchExists(b.Branch) {
360+
prunableCount++
361+
}
362+
}
363+
if prunableCount > 0 && cfg.IsInteractive() {
364+
prompt := fmt.Sprintf("Prune %d merged %s?",
365+
prunableCount, plural(prunableCount, "branch", "branches"))
366+
confirmed, err := confirmPrune(cfg, prompt, true)
367+
if err != nil {
368+
if isInterruptError(err) {
369+
printInterrupt(cfg)
370+
// Save state before exiting so PR sync isn't lost.
371+
_ = stack.Save(gitDir, sf)
372+
return ErrSilent
373+
}
374+
// On any other prompt error, skip pruning silently.
375+
} else {
376+
doPrune = confirmed
377+
}
378+
}
379+
}
380+
381+
if doPrune {
353382
merged := s.MergedBranches()
354383
var prunable []string
355384
for _, b := range merged {
@@ -395,7 +424,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
395424
if pruned > 0 {
396425
cfg.Successf("Pruned %d merged %s", pruned, plural(pruned, "branch", "branches"))
397426
}
398-
} else {
427+
} else if opts.prune {
399428
cfg.Printf("")
400429
cfg.Printf("No merged branches to prune")
401430
}
@@ -452,3 +481,12 @@ func short(sha string) string {
452481
}
453482
return sha
454483
}
484+
485+
// confirmPrune asks the user to confirm pruning via ConfirmFn or a terminal prompt.
486+
func confirmPrune(cfg *config.Config, prompt string, defaultValue bool) (bool, error) {
487+
if cfg.ConfirmFn != nil {
488+
return cfg.ConfirmFn(prompt, defaultValue)
489+
}
490+
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
491+
return p.Confirm(prompt, defaultValue)
492+
}

cmd/sync_test.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,3 +1286,171 @@ func TestSync_Prune_DeleteFailureContinues(t *testing.T) {
12861286
assert.Contains(t, output, "Pruned b2 (merged)")
12871287
assert.Contains(t, output, "Pruned 1 merged branch")
12881288
}
1289+
1290+
// TestSync_InteractivePrune_PromptsAndPrunes verifies that when running in an
1291+
// interactive terminal without --prune, the user is prompted and merged branches
1292+
// are pruned when they confirm.
1293+
func TestSync_InteractivePrune_PromptsAndPrunes(t *testing.T) {
1294+
s := stack.Stack{
1295+
Trunk: stack.BranchRef{Branch: "main"},
1296+
Branches: []stack.BranchRef{
1297+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
1298+
{Branch: "b2"},
1299+
},
1300+
}
1301+
1302+
tmpDir := t.TempDir()
1303+
writeStackFile(t, tmpDir, s)
1304+
1305+
var deletedBranches []string
1306+
var promptShown string
1307+
1308+
mock := newSyncMock(tmpDir, "b2")
1309+
mock.BranchExistsFn = func(name string) bool { return true }
1310+
mock.DeleteBranchFn = func(name string, force bool) error {
1311+
deletedBranches = append(deletedBranches, name)
1312+
return nil
1313+
}
1314+
1315+
restore := git.SetOps(mock)
1316+
defer restore()
1317+
1318+
cfg, _, errR := config.NewTestConfig()
1319+
cfg.ForceInteractive = true
1320+
cfg.ConfirmFn = func(prompt string, defaultValue bool) (bool, error) {
1321+
promptShown = prompt
1322+
assert.True(t, defaultValue, "default should be yes")
1323+
return true, nil // user confirms
1324+
}
1325+
1326+
cmd := SyncCmd(cfg)
1327+
// No --prune flag
1328+
cmd.SetOut(io.Discard)
1329+
cmd.SetErr(io.Discard)
1330+
err := cmd.Execute()
1331+
1332+
cfg.Err.Close()
1333+
errOut, _ := io.ReadAll(errR)
1334+
output := string(errOut)
1335+
1336+
assert.NoError(t, err)
1337+
assert.Contains(t, promptShown, "Prune 1 merged branch")
1338+
assert.Equal(t, []string{"b1"}, deletedBranches)
1339+
assert.Contains(t, output, "Pruned b1 (merged)")
1340+
}
1341+
1342+
// TestSync_InteractivePrune_UserDeclines verifies that when the user declines
1343+
// the prune prompt, no branches are deleted.
1344+
func TestSync_InteractivePrune_UserDeclines(t *testing.T) {
1345+
s := stack.Stack{
1346+
Trunk: stack.BranchRef{Branch: "main"},
1347+
Branches: []stack.BranchRef{
1348+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
1349+
{Branch: "b2"},
1350+
},
1351+
}
1352+
1353+
tmpDir := t.TempDir()
1354+
writeStackFile(t, tmpDir, s)
1355+
1356+
mock := newSyncMock(tmpDir, "b2")
1357+
mock.BranchExistsFn = func(name string) bool { return true }
1358+
mock.DeleteBranchFn = func(string, bool) error {
1359+
t.Fatal("DeleteBranch should not be called when user declines")
1360+
return nil
1361+
}
1362+
1363+
restore := git.SetOps(mock)
1364+
defer restore()
1365+
1366+
cfg, _, _ := config.NewTestConfig()
1367+
cfg.ForceInteractive = true
1368+
cfg.ConfirmFn = func(string, bool) (bool, error) {
1369+
return false, nil // user declines
1370+
}
1371+
1372+
cmd := SyncCmd(cfg)
1373+
cmd.SetOut(io.Discard)
1374+
cmd.SetErr(io.Discard)
1375+
err := cmd.Execute()
1376+
1377+
assert.NoError(t, err)
1378+
}
1379+
1380+
// TestSync_NonInteractive_NoPrunePrompt verifies that when the terminal is not
1381+
// interactive and --prune is not set, no prompt is shown and no branches are deleted.
1382+
func TestSync_NonInteractive_NoPrunePrompt(t *testing.T) {
1383+
s := stack.Stack{
1384+
Trunk: stack.BranchRef{Branch: "main"},
1385+
Branches: []stack.BranchRef{
1386+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
1387+
{Branch: "b2"},
1388+
},
1389+
}
1390+
1391+
tmpDir := t.TempDir()
1392+
writeStackFile(t, tmpDir, s)
1393+
1394+
mock := newSyncMock(tmpDir, "b2")
1395+
mock.BranchExistsFn = func(name string) bool { return true }
1396+
mock.DeleteBranchFn = func(string, bool) error {
1397+
t.Fatal("DeleteBranch should not be called in non-interactive mode without --prune")
1398+
return nil
1399+
}
1400+
1401+
restore := git.SetOps(mock)
1402+
defer restore()
1403+
1404+
cfg, _, _ := config.NewTestConfig()
1405+
// ForceInteractive is false by default — simulates non-interactive/CI/agent
1406+
1407+
cmd := SyncCmd(cfg)
1408+
cmd.SetOut(io.Discard)
1409+
cmd.SetErr(io.Discard)
1410+
err := cmd.Execute()
1411+
1412+
assert.NoError(t, err)
1413+
}
1414+
1415+
// TestSync_ExplicitPrune_SkipsPrompt verifies that --prune flag bypasses the
1416+
// interactive prompt and prunes directly.
1417+
func TestSync_ExplicitPrune_SkipsPrompt(t *testing.T) {
1418+
s := stack.Stack{
1419+
Trunk: stack.BranchRef{Branch: "main"},
1420+
Branches: []stack.BranchRef{
1421+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
1422+
{Branch: "b2"},
1423+
},
1424+
}
1425+
1426+
tmpDir := t.TempDir()
1427+
writeStackFile(t, tmpDir, s)
1428+
1429+
var deletedBranches []string
1430+
1431+
mock := newSyncMock(tmpDir, "b2")
1432+
mock.BranchExistsFn = func(name string) bool { return true }
1433+
mock.DeleteBranchFn = func(name string, force bool) error {
1434+
deletedBranches = append(deletedBranches, name)
1435+
return nil
1436+
}
1437+
1438+
restore := git.SetOps(mock)
1439+
defer restore()
1440+
1441+
cfg, _, _ := config.NewTestConfig()
1442+
cfg.ForceInteractive = true
1443+
cfg.ConfirmFn = func(string, bool) (bool, error) {
1444+
t.Fatal("ConfirmFn should not be called when --prune is explicit")
1445+
return false, nil
1446+
}
1447+
1448+
cmd := SyncCmd(cfg)
1449+
cmd.SetArgs([]string{"--prune"})
1450+
cmd.SetOut(io.Discard)
1451+
cmd.SetErr(io.Discard)
1452+
err := cmd.Execute()
1453+
1454+
assert.NoError(t, err)
1455+
assert.Equal(t, []string{"b1"}, deletedBranches)
1456+
}

internal/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ type Config struct {
3838
// SelectFn, when non-nil, is called instead of prompting via the
3939
// terminal. Used in tests to simulate interactive selection.
4040
SelectFn func(prompt, defaultValue string, options []string) (int, error)
41+
42+
// ConfirmFn, when non-nil, is called instead of prompting via the
43+
// terminal. Used in tests to simulate yes/no confirmation prompts.
44+
ConfirmFn func(prompt string, defaultValue bool) (bool, error)
4145
}
4246

4347
// New creates a new Config with terminal-aware output and color support.

0 commit comments

Comments
 (0)