Skip to content

Commit d3be1b5

Browse files
authored
prune merged branches (#94)
* prune merged branches * interactively prompt for prune * delete remote tracking ref too * disable selecting merged branches in TUIs * include full list (including merged PRs) in PUT request to stacks API * add prune to docs * addressing review comments * increment skill file version
1 parent 650a946 commit d3be1b5

17 files changed

Lines changed: 717 additions & 25 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,15 +310,20 @@ Performs a safe, non-interactive synchronization of the entire stack:
310310
3. **Cascade rebase** — rebases all stack branches onto their updated parents (only if trunk moved). If a conflict is detected, all branches are restored to their original state and you are advised to run `gh stack rebase` to resolve conflicts interactively
311311
4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred)
312312
5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR
313+
6. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically
313314

314315
| Flag | Description |
315316
|------|-------------|
316317
| `--remote <name>` | Remote to fetch from and push to (defaults to auto-detected remote) |
318+
| `--prune` | Delete local branches for merged PRs |
317319

318320
**Examples:**
319321

320322
```sh
321323
gh stack sync
324+
325+
# Sync and automatically prune merged branches
326+
gh stack sync --prune
322327
```
323328

324329
### `gh stack push`
@@ -556,6 +561,7 @@ gh stack push
556561

557562
# 8. When the first PR is merged, sync the stack
558563
gh stack sync
564+
# → prompts to prune merged branches (or use --prune to prune automatically and avoid the prompt)
559565
```
560566

561567
## Abbreviated workflow

cmd/submit.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -426,12 +426,11 @@ func clearPendingModifyState(cfg *config.Config, gitDir string) {
426426
// This is a best-effort operation: failures are reported as warnings but do
427427
// not cause the submit command to fail (the PRs are already created).
428428
func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) {
429-
// Collect PR numbers in stack order (bottom to top).
429+
// Collect PR numbers in stack order (bottom to top), including merged PRs.
430+
// The API expects the full list — omitting merged PRs causes a
431+
// "Stack contents have changed" rejection.
430432
var prNumbers []int
431433
for _, b := range s.Branches {
432-
if b.IsMerged() {
433-
continue
434-
}
435434
if b.PullRequest != nil {
436435
prNumbers = append(prNumbers, b.PullRequest.Number)
437436
}

cmd/submit_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -730,7 +730,7 @@ func TestSyncStack_SkippedForSinglePR(t *testing.T) {
730730
assert.False(t, updateCalled, "UpdateStack should not be called with fewer than 2 PRs")
731731
}
732732

733-
func TestSyncStack_SkipsMergedBranches(t *testing.T) {
733+
func TestSyncStack_IncludesMergedBranches(t *testing.T) {
734734
s := &stack.Stack{
735735
Trunk: stack.BranchRef{Branch: "main"},
736736
Branches: []stack.BranchRef{
@@ -752,7 +752,7 @@ func TestSyncStack_SkipsMergedBranches(t *testing.T) {
752752
syncStack(cfg, mock, s)
753753
cfg.Err.Close()
754754

755-
assert.Equal(t, []int{11, 12}, gotNumbers, "should only include non-merged PRs")
755+
assert.Equal(t, []int{10, 11, 12}, gotNumbers, "should include merged PRs to keep API in sync")
756756
}
757757

758758
func TestSyncStack_SkipsBranchesWithoutPR(t *testing.T) {

cmd/sync.go

Lines changed: 107 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"
@@ -14,6 +15,7 @@ import (
1415

1516
type syncOptions struct {
1617
remote string
18+
prune bool
1719
}
1820

1921
func SyncCmd(cfg *config.Config) *cobra.Command {
@@ -34,13 +36,19 @@ This command performs a safe, non-interactive synchronization:
3436
3537
If a rebase conflict is detected, all branches are restored to their
3638
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.`,
3845
RunE: func(cmd *cobra.Command, args []string) error {
3946
return runSync(cfg, opts)
4047
},
4148
}
4249

4350
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")
4452

4553
return cmd
4654
}
@@ -341,7 +349,95 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
341349
cfg.Printf("Merged: %s", strings.Join(names, ", "))
342350
}
343351

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 ---
345441
updateBaseSHAs(s)
346442

347443
if err := stack.Save(gitDir, sf); err != nil {
@@ -392,3 +488,12 @@ func short(sha string) string {
392488
}
393489
return sha
394490
}
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+
}

0 commit comments

Comments
 (0)