|
5 | 5 | "fmt" |
6 | 6 | "strings" |
7 | 7 |
|
| 8 | + "github.com/cli/go-gh/v2/pkg/prompter" |
8 | 9 | "github.com/github/gh-stack/internal/config" |
9 | 10 | "github.com/github/gh-stack/internal/git" |
10 | 11 | "github.com/github/gh-stack/internal/modify" |
@@ -14,6 +15,7 @@ import ( |
14 | 15 |
|
15 | 16 | type syncOptions struct { |
16 | 17 | remote string |
| 18 | + prune bool |
17 | 19 | } |
18 | 20 |
|
19 | 21 | func SyncCmd(cfg *config.Config) *cobra.Command { |
@@ -34,13 +36,19 @@ This command performs a safe, non-interactive synchronization: |
34 | 36 |
|
35 | 37 | If a rebase conflict is detected, all branches are restored to their |
36 | 38 | original state and you are advised to run "gh stack rebase" to resolve |
37 | | -conflicts interactively.`, |
| 39 | +conflicts interactively. |
| 40 | +
|
| 41 | +Use --prune to delete local branches for merged PRs. Stack metadata is |
| 42 | +preserved so that rebase and display logic continue to work correctly. |
| 43 | +If you are on a branch that would be pruned, your checkout is moved to |
| 44 | +the first active branch in the stack, or the trunk if all are merged.`, |
38 | 45 | RunE: func(cmd *cobra.Command, args []string) error { |
39 | 46 | return runSync(cfg, opts) |
40 | 47 | }, |
41 | 48 | } |
42 | 49 |
|
43 | 50 | cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from and push to (defaults to auto-detected remote)") |
| 51 | + cmd.Flags().BoolVar(&opts.prune, "prune", false, "Delete local branches for merged PRs") |
44 | 52 |
|
45 | 53 | return cmd |
46 | 54 | } |
@@ -341,7 +349,95 @@ func runSync(cfg *config.Config, opts *syncOptions) error { |
341 | 349 | cfg.Printf("Merged: %s", strings.Join(names, ", ")) |
342 | 350 | } |
343 | 351 |
|
344 | | - // --- Step 6: Update base SHAs and save --- |
| 352 | + // --- Step 6: Prune merged branches (optional) --- |
| 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 { |
| 382 | + merged := s.MergedBranches() |
| 383 | + var prunable []string |
| 384 | + for _, b := range merged { |
| 385 | + if git.BranchExists(b.Branch) { |
| 386 | + prunable = append(prunable, b.Branch) |
| 387 | + } |
| 388 | + } |
| 389 | + |
| 390 | + if len(prunable) > 0 { |
| 391 | + // If the current branch is being pruned, switch away first. |
| 392 | + needsSwitch := false |
| 393 | + for _, name := range prunable { |
| 394 | + if name == currentBranch { |
| 395 | + needsSwitch = true |
| 396 | + break |
| 397 | + } |
| 398 | + } |
| 399 | + if needsSwitch { |
| 400 | + switchTarget := trunk |
| 401 | + for _, b := range s.Branches { |
| 402 | + if !b.IsSkipped() { |
| 403 | + switchTarget = b.Branch |
| 404 | + break |
| 405 | + } |
| 406 | + } |
| 407 | + if err := git.CheckoutBranch(switchTarget); err != nil { |
| 408 | + cfg.Warningf("Failed to switch from %s to %s: %v", currentBranch, switchTarget, err) |
| 409 | + } else { |
| 410 | + currentBranch = switchTarget |
| 411 | + } |
| 412 | + } |
| 413 | + |
| 414 | + cfg.Printf("") |
| 415 | + pruned := 0 |
| 416 | + for _, name := range prunable { |
| 417 | + if err := git.DeleteBranch(name, true); err != nil { |
| 418 | + cfg.Warningf("Failed to delete %s: %v", name, err) |
| 419 | + } else { |
| 420 | + cfg.Successf("Pruned %s (merged)", name) |
| 421 | + pruned++ |
| 422 | + } |
| 423 | + } |
| 424 | + if pruned > 0 { |
| 425 | + cfg.Successf("Pruned %d merged %s", pruned, plural(pruned, "branch", "branches")) |
| 426 | + } |
| 427 | + } else if opts.prune { |
| 428 | + cfg.Printf("") |
| 429 | + cfg.Printf("No merged branches to prune") |
| 430 | + } |
| 431 | + |
| 432 | + // Clean up remote-tracking refs for all merged branches, even if |
| 433 | + // the local branch was already deleted. This prevents |
| 434 | + // `git checkout <name>` from resurrecting the branch. |
| 435 | + for _, b := range merged { |
| 436 | + _ = git.DeleteTrackingRef(remote, b.Branch) |
| 437 | + } |
| 438 | + } |
| 439 | + |
| 440 | + // --- Step 7: Update base SHAs and save --- |
345 | 441 | updateBaseSHAs(s) |
346 | 442 |
|
347 | 443 | if err := stack.Save(gitDir, sf); err != nil { |
@@ -392,3 +488,12 @@ func short(sha string) string { |
392 | 488 | } |
393 | 489 | return sha |
394 | 490 | } |
| 491 | + |
| 492 | +// confirmPrune asks the user to confirm pruning via ConfirmFn or a terminal prompt. |
| 493 | +func confirmPrune(cfg *config.Config, prompt string, defaultValue bool) (bool, error) { |
| 494 | + if cfg.ConfirmFn != nil { |
| 495 | + return cfg.ConfirmFn(prompt, defaultValue) |
| 496 | + } |
| 497 | + p := prompter.New(cfg.In, cfg.Out, cfg.Err) |
| 498 | + return p.Confirm(prompt, defaultValue) |
| 499 | +} |