From 677a702da414d4325425cbcce14910b4c405c08a Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 15 May 2026 08:44:31 -0400 Subject: [PATCH 1/8] prune merged branches --- cmd/sync.go | 64 +++++++++++- cmd/sync_test.go | 267 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 2 deletions(-) diff --git a/cmd/sync.go b/cmd/sync.go index b003114..9d6f01a 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -14,6 +14,7 @@ import ( type syncOptions struct { remote string + prune bool } func SyncCmd(cfg *config.Config) *cobra.Command { @@ -34,13 +35,19 @@ This command performs a safe, non-interactive synchronization: If a rebase conflict is detected, all branches are restored to their original state and you are advised to run "gh stack rebase" to resolve -conflicts interactively.`, +conflicts interactively. + +Use --prune to delete local branches for merged PRs. Stack metadata is +preserved so that rebase and display logic continue to work correctly. +If you are on a branch that would be pruned, your checkout is moved to +the nearest active branch or the trunk.`, RunE: func(cmd *cobra.Command, args []string) error { return runSync(cfg, opts) }, } cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from and push to (defaults to auto-detected remote)") + cmd.Flags().BoolVar(&opts.prune, "prune", false, "Delete local branches for merged PRs") return cmd } @@ -341,7 +348,60 @@ func runSync(cfg *config.Config, opts *syncOptions) error { cfg.Printf("Merged: %s", strings.Join(names, ", ")) } - // --- Step 6: Update base SHAs and save --- + // --- Step 6: Prune merged branches (optional) --- + if opts.prune { + merged := s.MergedBranches() + var prunable []string + for _, b := range merged { + if git.BranchExists(b.Branch) { + prunable = append(prunable, b.Branch) + } + } + + if len(prunable) > 0 { + // If the current branch is being pruned, switch away first. + needsSwitch := false + for _, name := range prunable { + if name == currentBranch { + needsSwitch = true + break + } + } + if needsSwitch { + switchTarget := trunk + for _, b := range s.Branches { + if !b.IsSkipped() { + switchTarget = b.Branch + break + } + } + if err := git.CheckoutBranch(switchTarget); err != nil { + cfg.Warningf("Failed to switch from %s to %s: %v", currentBranch, switchTarget, err) + } else { + currentBranch = switchTarget + } + } + + cfg.Printf("") + pruned := 0 + for _, name := range prunable { + if err := git.DeleteBranch(name, true); err != nil { + cfg.Warningf("Failed to delete %s: %v", name, err) + } else { + cfg.Successf("Pruned %s (merged)", name) + pruned++ + } + } + if pruned > 0 { + cfg.Successf("Pruned %d merged %s", pruned, plural(pruned, "branch", "branches")) + } + } else { + cfg.Printf("") + cfg.Printf("No merged branches to prune") + } + } + + // --- Step 7: Update base SHAs and save --- updateBaseSHAs(s) if err := stack.Save(gitDir, sf); err != nil { diff --git a/cmd/sync_test.go b/cmd/sync_test.go index e3dc532..67f1129 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -1019,3 +1019,270 @@ func TestSync_MergedBranchDeletedFromRemote(t *testing.T) { assert.Equal(t, "main", rebaseOntoCalls[0].newBase) assert.Equal(t, "b1-stored-head-sha", rebaseOntoCalls[0].oldBase) } + +// TestSync_Prune_DeletesMergedBranches verifies that --prune deletes local +// branches for merged PRs while keeping them in the stack metadata. +func TestSync_Prune_DeletesMergedBranches(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var deletedBranches []string + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(name string, force bool) error { + deletedBranches = append(deletedBranches, name) + assert.True(t, force, "should force-delete merged branch") + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetArgs([]string{"--prune"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Equal(t, []string{"b1"}, deletedBranches) + assert.Contains(t, output, "Pruned b1 (merged)") + assert.Contains(t, output, "Pruned 1 merged branch") +} + +// TestSync_Prune_SkipsNonExistentBranches verifies that --prune does not +// attempt to delete branches that have already been removed locally. +func TestSync_Prune_SkipsNonExistentBranches(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", Head: "sha-b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { + return name != "b1" // b1 already deleted + } + mock.DeleteBranchFn = func(string, bool) error { + t.Fatal("DeleteBranch should not be called for non-existent branches") + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetArgs([]string{"--prune"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "No merged branches to prune") +} + +// TestSync_Prune_SwitchesToLowestUnmergedBranch verifies that when the user is +// on a merged branch being pruned, checkout moves to the lowest active branch. +func TestSync_Prune_SwitchesToLowestUnmergedBranch(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var deletedBranches []string + var checkoutTarget string + + mock := newSyncMock(tmpDir, "b1") // currently on merged branch + mock.BranchExistsFn = func(name string) bool { return true } + mock.CheckoutBranchFn = func(name string) error { + checkoutTarget = name + return nil + } + mock.DeleteBranchFn = func(name string, force bool) error { + deletedBranches = append(deletedBranches, name) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetArgs([]string{"--prune"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Equal(t, []string{"b1"}, deletedBranches) + // Should have switched to b2 (first active branch), not trunk + assert.Equal(t, "b2", checkoutTarget) + assert.Contains(t, output, "Pruned b1 (merged)") +} + +// TestSync_Prune_SwitchesToTrunkWhenAllMerged verifies that when all branches +// are merged, checkout moves to the trunk. +func TestSync_Prune_SwitchesToTrunkWhenAllMerged(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var deletedBranches []string + var checkoutTarget string + + mock := newSyncMock(tmpDir, "b1") // currently on merged branch + mock.BranchExistsFn = func(name string) bool { return true } + mock.CheckoutBranchFn = func(name string) error { + checkoutTarget = name + return nil + } + mock.DeleteBranchFn = func(name string, force bool) error { + deletedBranches = append(deletedBranches, name) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetArgs([]string{"--prune"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Equal(t, []string{"b1", "b2"}, deletedBranches) + // Should have switched to trunk since all branches are merged + assert.Equal(t, "main", checkoutTarget) + assert.Contains(t, output, "Pruned 2 merged branches") +} + +// TestSync_NoPrune_DoesNotDeleteBranches verifies that without --prune, +// merged branches are not deleted (default behavior is unchanged). +func TestSync_NoPrune_DoesNotDeleteBranches(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(string, bool) error { + t.Fatal("DeleteBranch should not be called without --prune") + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := SyncCmd(cfg) + // No --prune flag + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) +} + +// TestSync_Prune_DeleteFailureContinues verifies that a failed branch deletion +// logs a warning and does not abort the sync. +func TestSync_Prune_DeleteFailureContinues(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var deletedBranches []string + + mock := newSyncMock(tmpDir, "b3") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(name string, force bool) error { + if name == "b1" { + return fmt.Errorf("permission denied") + } + deletedBranches = append(deletedBranches, name) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetArgs([]string{"--prune"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + // b1 failed, b2 succeeded + assert.Equal(t, []string{"b2"}, deletedBranches) + assert.Contains(t, output, "Failed to delete b1") + assert.Contains(t, output, "Pruned b2 (merged)") + assert.Contains(t, output, "Pruned 1 merged branch") +} From 4a30bb65ee50cd6e57dc26b93d1560bb53507ed7 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 15 May 2026 09:27:31 -0400 Subject: [PATCH 2/8] interactively prompt for prune --- cmd/sync.go | 42 +++++++++- cmd/sync_test.go | 168 ++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 4 + 3 files changed, 212 insertions(+), 2 deletions(-) diff --git a/cmd/sync.go b/cmd/sync.go index 9d6f01a..c79e822 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/modify" @@ -349,7 +350,35 @@ func runSync(cfg *config.Config, opts *syncOptions) error { } // --- Step 6: Prune merged branches (optional) --- - if opts.prune { + doPrune := opts.prune + if !doPrune { + // --prune was not provided. If interactive, prompt. + merged := s.MergedBranches() + var prunableCount int + for _, b := range merged { + if git.BranchExists(b.Branch) { + prunableCount++ + } + } + if prunableCount > 0 && cfg.IsInteractive() { + prompt := fmt.Sprintf("Prune %d merged %s?", + prunableCount, plural(prunableCount, "branch", "branches")) + confirmed, err := confirmPrune(cfg, prompt, true) + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + // Save state before exiting so PR sync isn't lost. + _ = stack.Save(gitDir, sf) + return ErrSilent + } + // On any other prompt error, skip pruning silently. + } else { + doPrune = confirmed + } + } + } + + if doPrune { merged := s.MergedBranches() var prunable []string for _, b := range merged { @@ -395,7 +424,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error { if pruned > 0 { cfg.Successf("Pruned %d merged %s", pruned, plural(pruned, "branch", "branches")) } - } else { + } else if opts.prune { cfg.Printf("") cfg.Printf("No merged branches to prune") } @@ -452,3 +481,12 @@ func short(sha string) string { } return sha } + +// confirmPrune asks the user to confirm pruning via ConfirmFn or a terminal prompt. +func confirmPrune(cfg *config.Config, prompt string, defaultValue bool) (bool, error) { + if cfg.ConfirmFn != nil { + return cfg.ConfirmFn(prompt, defaultValue) + } + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + return p.Confirm(prompt, defaultValue) +} diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 67f1129..e25d239 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -1286,3 +1286,171 @@ func TestSync_Prune_DeleteFailureContinues(t *testing.T) { assert.Contains(t, output, "Pruned b2 (merged)") assert.Contains(t, output, "Pruned 1 merged branch") } + +// TestSync_InteractivePrune_PromptsAndPrunes verifies that when running in an +// interactive terminal without --prune, the user is prompted and merged branches +// are pruned when they confirm. +func TestSync_InteractivePrune_PromptsAndPrunes(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var deletedBranches []string + var promptShown string + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(name string, force bool) error { + deletedBranches = append(deletedBranches, name) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.ConfirmFn = func(prompt string, defaultValue bool) (bool, error) { + promptShown = prompt + assert.True(t, defaultValue, "default should be yes") + return true, nil // user confirms + } + + cmd := SyncCmd(cfg) + // No --prune flag + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, promptShown, "Prune 1 merged branch") + assert.Equal(t, []string{"b1"}, deletedBranches) + assert.Contains(t, output, "Pruned b1 (merged)") +} + +// TestSync_InteractivePrune_UserDeclines verifies that when the user declines +// the prune prompt, no branches are deleted. +func TestSync_InteractivePrune_UserDeclines(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(string, bool) error { + t.Fatal("DeleteBranch should not be called when user declines") + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.ConfirmFn = func(string, bool) (bool, error) { + return false, nil // user declines + } + + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) +} + +// TestSync_NonInteractive_NoPrunePrompt verifies that when the terminal is not +// interactive and --prune is not set, no prompt is shown and no branches are deleted. +func TestSync_NonInteractive_NoPrunePrompt(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(string, bool) error { + t.Fatal("DeleteBranch should not be called in non-interactive mode without --prune") + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + // ForceInteractive is false by default — simulates non-interactive/CI/agent + + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) +} + +// TestSync_ExplicitPrune_SkipsPrompt verifies that --prune flag bypasses the +// interactive prompt and prunes directly. +func TestSync_ExplicitPrune_SkipsPrompt(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var deletedBranches []string + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(name string, force bool) error { + deletedBranches = append(deletedBranches, name) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.ConfirmFn = func(string, bool) (bool, error) { + t.Fatal("ConfirmFn should not be called when --prune is explicit") + return false, nil + } + + cmd := SyncCmd(cfg) + cmd.SetArgs([]string{"--prune"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b1"}, deletedBranches) +} diff --git a/internal/config/config.go b/internal/config/config.go index e706dd0..8b99f9a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,6 +38,10 @@ type Config struct { // SelectFn, when non-nil, is called instead of prompting via the // terminal. Used in tests to simulate interactive selection. SelectFn func(prompt, defaultValue string, options []string) (int, error) + + // ConfirmFn, when non-nil, is called instead of prompting via the + // terminal. Used in tests to simulate yes/no confirmation prompts. + ConfirmFn func(prompt string, defaultValue bool) (bool, error) } // New creates a new Config with terminal-aware output and color support. From c9d9ad6a5e8478dfeb0459e16192b84963a249ee Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 15 May 2026 09:44:36 -0400 Subject: [PATCH 3/8] delete remote tracking ref too --- cmd/sync.go | 3 +++ internal/git/git.go | 5 +++++ internal/git/gitops.go | 5 +++++ internal/git/mock_ops.go | 8 ++++++++ 4 files changed, 21 insertions(+) diff --git a/cmd/sync.go b/cmd/sync.go index c79e822..94113c6 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -417,6 +417,9 @@ func runSync(cfg *config.Config, opts *syncOptions) error { if err := git.DeleteBranch(name, true); err != nil { cfg.Warningf("Failed to delete %s: %v", name, err) } else { + // Also remove the remote-tracking ref so that + // `git checkout ` doesn't recreate the branch. + _ = git.DeleteTrackingRef(remote, name) cfg.Successf("Pruned %s (merged)", name) pruned++ } diff --git a/internal/git/git.go b/internal/git/git.go index 7be063e..5a2ae52 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -312,6 +312,11 @@ func DeleteRemoteBranch(remote, branch string) error { return ops.DeleteRemoteBranch(remote, branch) } +// DeleteTrackingRef deletes a local remote-tracking ref (e.g. refs/remotes/origin/branch). +func DeleteTrackingRef(remote, branch string) error { + return ops.DeleteTrackingRef(remote, branch) +} + // ResetHard resets the current branch to the given ref. func ResetHard(ref string) error { return ops.ResetHard(ref) diff --git a/internal/git/gitops.go b/internal/git/gitops.go index a00deac..9402fee 100644 --- a/internal/git/gitops.go +++ b/internal/git/gitops.go @@ -48,6 +48,7 @@ type Ops interface { DiffStatFiles(base, head string) ([]FileDiffStat, error) DeleteBranch(name string, force bool) error DeleteRemoteBranch(remote, branch string) error + DeleteTrackingRef(remote, branch string) error ResetHard(ref string) error SetUpstreamTracking(branch, remote string) error MergeFF(target string) error @@ -485,6 +486,10 @@ func (d *defaultOps) DeleteRemoteBranch(remote, branch string) error { return runSilent("push", remote, "--delete", branch) } +func (d *defaultOps) DeleteTrackingRef(remote, branch string) error { + return runSilent("branch", "-dr", remote+"/"+branch) +} + func (d *defaultOps) ResetHard(ref string) error { return runSilent("reset", "--hard", ref) } diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index ed123c5..c19a001 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -36,6 +36,7 @@ type MockOps struct { DiffStatFilesFn func(string, string) ([]FileDiffStat, error) DeleteBranchFn func(string, bool) error DeleteRemoteBranchFn func(string, string) error + DeleteTrackingRefFn func(string, string) error ResetHardFn func(string) error SetUpstreamTrackingFn func(string, string) error MergeFFFn func(string) error @@ -287,6 +288,13 @@ func (m *MockOps) DeleteRemoteBranch(remote, branch string) error { return nil } +func (m *MockOps) DeleteTrackingRef(remote, branch string) error { + if m.DeleteTrackingRefFn != nil { + return m.DeleteTrackingRefFn(remote, branch) + } + return nil +} + func (m *MockOps) ResetHard(ref string) error { if m.ResetHardFn != nil { return m.ResetHardFn(ref) From 691f469d85519207a9c2e572fe355ea941b3fa17 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 15 May 2026 10:07:56 -0400 Subject: [PATCH 4/8] disable selecting merged branches in TUIs --- internal/tui/modifyview/model.go | 17 +++++++--- internal/tui/modifyview/model_test.go | 18 ++++++++++ internal/tui/stackview/model.go | 44 ++++++++++++++++++------- internal/tui/stackview/model_test.go | 47 +++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 15 deletions(-) diff --git a/internal/tui/modifyview/model.go b/internal/tui/modifyview/model.go index 75cf852..eff9e79 100644 --- a/internal/tui/modifyview/model.go +++ b/internal/tui/modifyview/model.go @@ -454,12 +454,16 @@ func (m *Model) currentMode() actionMode { return modeNone } -// moveCursor moves the cursor by delta to the next node. +// moveCursor moves the cursor by delta, skipping merged branches. func (m *Model) moveCursor(delta int) { next := m.cursor + delta - if next >= 0 && next < len(m.nodes) { - m.cursor = next - m.ensureVisible() + for next >= 0 && next < len(m.nodes) { + if !m.nodes[next].Ref.IsMerged() { + m.cursor = next + m.ensureVisible() + return + } + next += delta } } @@ -866,6 +870,11 @@ func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) { return m, nil } + // Don't allow selecting merged branches. + if m.nodes[result.NodeIndex].Ref.IsMerged() { + return m, nil + } + m.cursor = result.NodeIndex if result.OpenURL != "" { diff --git a/internal/tui/modifyview/model_test.go b/internal/tui/modifyview/model_test.go index 348515e..6afc13d 100644 --- a/internal/tui/modifyview/model_test.go +++ b/internal/tui/modifyview/model_test.go @@ -756,3 +756,21 @@ func TestUndoRename_DoesNotAffectOtherRenames(t *testing.T) { require.NotNil(t, m.nodes[0].PendingAction, "first rename should still be intact") assert.Equal(t, "a-renamed", m.nodes[0].PendingAction.NewName, "first rename new name should be unchanged") } + +func TestCursorNavigation_SkipsMergedBranches(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + makeMergedNode("merged", 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + require.Equal(t, 0, m.cursor, "cursor should start on first non-merged") + + // Move down should skip merged and land on c + m = sendKey(t, m, runeKey('j')) + assert.Equal(t, 2, m.cursor, "down should skip merged branch") + + // Move up should skip merged and land back on a + m = sendKey(t, m, runeKey('k')) + assert.Equal(t, 0, m.cursor, "up should skip merged branch") +} diff --git a/internal/tui/stackview/model.go b/internal/tui/stackview/model.go index 7c7159c..49cd46f 100644 --- a/internal/tui/stackview/model.go +++ b/internal/tui/stackview/model.go @@ -83,14 +83,24 @@ func New(nodes []BranchNode, trunk stack.BranchRef, version string) Model { h := help.New() h.ShowAll = true - // Cursor starts at the current branch, or top of stack + // Cursor starts at the current branch, or first non-merged branch cursor := 0 + found := false for i, n := range nodes { - if n.IsCurrent { + if n.IsCurrent && !n.Ref.IsMerged() { cursor = i + found = true break } } + if !found { + for i, n := range nodes { + if !n.Ref.IsMerged() { + cursor = i + break + } + } + } return Model{ nodes: nodes, @@ -124,17 +134,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case key.Matches(msg, keys.Up): - if m.cursor > 0 { - m.cursor-- - m.ensureVisible() - } + m.moveCursor(-1) return m, nil case key.Matches(msg, keys.Down): - if m.cursor < len(m.nodes)-1 { - m.cursor++ - m.ensureVisible() - } + m.moveCursor(1) return m, nil case key.Matches(msg, keys.ToggleCommits): @@ -165,7 +169,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.Checkout): if m.cursor >= 0 && m.cursor < len(m.nodes) { node := m.nodes[m.cursor] - if !node.IsCurrent { + if !node.IsCurrent && !node.Ref.IsMerged() { m.checkoutBranch = node.Ref.Branch return m, tea.Quit } @@ -226,6 +230,11 @@ func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) { return m, nil } + // Don't allow selecting merged branches. + if m.nodes[result.NodeIndex].Ref.IsMerged() { + return m, nil + } + m.cursor = result.NodeIndex if result.OpenURL != "" { @@ -248,6 +257,19 @@ func (m Model) nodeLineCount(idx int) int { return shared.NodeLineCount(toBranchNodeData(m.nodes[idx])) } +// moveCursor moves the cursor by delta, skipping merged branches. +func (m *Model) moveCursor(delta int) { + next := m.cursor + delta + for next >= 0 && next < len(m.nodes) { + if !m.nodes[next].Ref.IsMerged() { + m.cursor = next + m.ensureVisible() + return + } + next += delta + } +} + // ensureVisible adjusts scroll offset so the cursor is visible. func (m *Model) ensureVisible() { if m.height == 0 { diff --git a/internal/tui/stackview/model_test.go b/internal/tui/stackview/model_test.go index fb1774e..b351d09 100644 --- a/internal/tui/stackview/model_test.go +++ b/internal/tui/stackview/model_test.go @@ -341,3 +341,50 @@ func TestScrollClamp_CannotScrollPastContent(t *testing.T) { view := m.View() assert.Contains(t, view, "b1", "content should still be visible after excessive scrolling") } + +func TestUpdate_CursorSkipsMergedBranches(t *testing.T) { + nodes := makeNodes("b1", "b2", "b3") + nodes[1].Ref.PullRequest = &stack.PullRequestRef{Number: 2, Merged: true} + m := New(nodes, testTrunk, "0.0.1") + assert.Equal(t, 0, m.cursor, "cursor should start on first non-merged branch") + + // Down should skip b2 (merged) and land on b3 + updated, _ := m.Update(keyMsg("down")) + m = updated.(Model) + assert.Equal(t, 2, m.cursor, "down should skip merged b2 and land on b3") + + // Up should skip b2 (merged) and land back on b1 + updated, _ = m.Update(keyMsg("up")) + m = updated.(Model) + assert.Equal(t, 0, m.cursor, "up should skip merged b2 and land on b1") +} + +func TestNew_CursorSkipsMergedBranch(t *testing.T) { + nodes := makeNodes("b1", "b2", "b3") + nodes[0].Ref.PullRequest = &stack.PullRequestRef{Number: 1, Merged: true} + m := New(nodes, testTrunk, "0.0.1") + assert.Equal(t, 1, m.cursor, "cursor should skip merged b1 and start on b2") +} + +func TestNew_CursorSkipsMergedCurrentBranch(t *testing.T) { + nodes := makeNodes("b1", "b2", "b3") + nodes[0].IsCurrent = true + nodes[0].Ref.PullRequest = &stack.PullRequestRef{Number: 1, Merged: true} + m := New(nodes, testTrunk, "0.0.1") + assert.Equal(t, 1, m.cursor, "cursor should not start on merged current branch") +} + +func TestUpdate_EnterOnMergedDoesNothing(t *testing.T) { + // All non-merged so we can navigate, but force cursor onto a merged node + // by having b1 active and b2 merged and b3 active. + nodes := makeNodes("b1", "b2") + nodes[0].Ref.PullRequest = &stack.PullRequestRef{Number: 1, Merged: true} + m := New(nodes, testTrunk, "0.0.1") + // Cursor is on b2 (first non-merged). Manually set to b1 to test guard. + m.cursor = 0 + + updated, cmd := m.Update(keyMsg("enter")) + m = updated.(Model) + assert.Equal(t, "", m.CheckoutBranch(), "enter on merged branch should not set checkout") + assert.Nil(t, cmd, "enter on merged branch should not quit") +} From d985618c85fa23b40426e78e91cbdd14c5529400 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 15 May 2026 10:10:51 -0400 Subject: [PATCH 5/8] include full list (including merged PRs) in PUT request to stacks API --- cmd/submit.go | 7 +++---- cmd/submit_test.go | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cmd/submit.go b/cmd/submit.go index 86e02ed..a57fe06 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -426,12 +426,11 @@ func clearPendingModifyState(cfg *config.Config, gitDir string) { // This is a best-effort operation: failures are reported as warnings but do // not cause the submit command to fail (the PRs are already created). func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) { - // Collect PR numbers in stack order (bottom to top). + // Collect PR numbers in stack order (bottom to top), including merged PRs. + // The API expects the full list — omitting merged PRs causes a + // "Stack contents have changed" rejection. var prNumbers []int for _, b := range s.Branches { - if b.IsMerged() { - continue - } if b.PullRequest != nil { prNumbers = append(prNumbers, b.PullRequest.Number) } diff --git a/cmd/submit_test.go b/cmd/submit_test.go index 1d86ed8..5164993 100644 --- a/cmd/submit_test.go +++ b/cmd/submit_test.go @@ -730,7 +730,7 @@ func TestSyncStack_SkippedForSinglePR(t *testing.T) { assert.False(t, updateCalled, "UpdateStack should not be called with fewer than 2 PRs") } -func TestSyncStack_SkipsMergedBranches(t *testing.T) { +func TestSyncStack_IncludesMergedBranches(t *testing.T) { s := &stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ @@ -752,7 +752,7 @@ func TestSyncStack_SkipsMergedBranches(t *testing.T) { syncStack(cfg, mock, s) cfg.Err.Close() - assert.Equal(t, []int{11, 12}, gotNumbers, "should only include non-merged PRs") + assert.Equal(t, []int{10, 11, 12}, gotNumbers, "should include merged PRs to keep API in sync") } func TestSyncStack_SkipsBranchesWithoutPR(t *testing.T) { From bfebb7f26fcc61e66b2b384ff04ffe99715eb878 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 15 May 2026 11:38:06 -0400 Subject: [PATCH 6/8] add prune to docs --- README.md | 6 ++++++ docs/src/content/docs/guides/stacked-prs.md | 2 +- docs/src/content/docs/guides/workflows.md | 1 + docs/src/content/docs/reference/cli.md | 5 +++++ skills/gh-stack/SKILL.md | 9 +++++++++ 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e447b17..f8a5fd3 100644 --- a/README.md +++ b/README.md @@ -310,15 +310,20 @@ Performs a safe, non-interactive synchronization of the entire stack: 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 4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred) 5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR +6. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically | Flag | Description | |------|-------------| | `--remote ` | Remote to fetch from and push to (defaults to auto-detected remote) | +| `--prune` | Delete local branches for merged PRs | **Examples:** ```sh gh stack sync + +# Sync and automatically prune merged branches +gh stack sync --prune ``` ### `gh stack push` @@ -556,6 +561,7 @@ gh stack push # 8. When the first PR is merged, sync the stack gh stack sync +# → prompts to prune merged branches (or use --prune to prune automatically and avoid the prompt) ``` ## Abbreviated workflow diff --git a/docs/src/content/docs/guides/stacked-prs.md b/docs/src/content/docs/guides/stacked-prs.md index 5be37ce..1f92493 100644 --- a/docs/src/content/docs/guides/stacked-prs.md +++ b/docs/src/content/docs/guides/stacked-prs.md @@ -49,4 +49,4 @@ gh stack sync - **`gh stack push`** pushes branches only (uses `--force-with-lease` for safety). It does not create or update PRs. - **`gh stack submit`** pushes branches and creates or updates PRs, linking them as a Stack on GitHub. -- **`gh stack sync`** is the all-in-one command: fetch, rebase, push, and sync PR state. +- **`gh stack sync`** is the all-in-one command: fetch, rebase, push, sync PR state, and optionally prune local branches for merged PRs. diff --git a/docs/src/content/docs/guides/workflows.md b/docs/src/content/docs/guides/workflows.md index 62ea8be..601f4a9 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -134,6 +134,7 @@ This command: 3. Rebases all remaining stack branches onto the updated trunk 4. Pushes the updated branches 5. Syncs PR state from GitHub +6. Prompts to prune local branches for merged PRs (use `--prune` to prune automatically) If a conflict is detected during the rebase, all branches are restored to their original state, and you're advised to run `gh stack rebase` to resolve conflicts interactively. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 9cab10b..5d08fc3 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -295,15 +295,20 @@ Performs a safe, non-interactive synchronization of the entire stack: 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. 4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred). 5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR. +6. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically. | Flag | Description | |------|-------------| | `--remote ` | Remote to fetch from and push to (defaults to auto-detected remote) | +| `--prune` | Delete local branches for merged PRs | **Examples:** ```sh gh stack sync + +# Sync and automatically prune merged branches +gh stack sync --prune ``` ### `gh stack rebase` diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 7f7105e..10c0c47 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -152,6 +152,7 @@ Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current | Create PRs as ready for review | `gh stack submit --auto --open` | | Sync (fetch, rebase, push) | `gh stack sync` | | Sync with specific remote | `gh stack sync --remote origin` | +| Sync and prune merged branches | `gh stack sync --prune` | | Rebase entire stack | `gh stack rebase` | | Rebase upstack only | `gh stack rebase --upstack` | | Continue after conflict | `gh stack rebase --continue` | @@ -305,8 +306,13 @@ gh stack push ```bash # Single command: fetch, rebase, push, sync PR state gh stack sync + +# Sync and automatically clean up local branches for merged PRs +gh stack sync --prune ``` +> **Note for agents:** In non-interactive environments, the prune prompt is not shown. Use `--prune` explicitly to delete local branches for merged PRs. + ### Squash-merge recovery When a PR is squash-merged on GitHub, the original branch's commits no longer exist in the trunk history. `gh stack` detects this automatically and uses `git rebase --onto` to correctly replay remaining commits. @@ -615,6 +621,7 @@ gh stack sync [flags] | Flag | Description | |------|-------------| | `--remote ` | Remote to fetch from and push to (use if multiple remotes exist) | +| `--prune` | Delete local branches for merged PRs | **What it does (in order):** @@ -623,6 +630,7 @@ gh stack sync [flags] 3. **Cascade rebase** all stack branches onto their updated parents (only if trunk moved). Handles merged PRs automatically. If a conflict is detected, **all branches are restored** to their pre-rebase state and the command exits with code 3 — see [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) for the resolution workflow 4. **Push** all active branches atomically 5. **Sync PR state** from GitHub and report the status of each PR +6. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to skip the prompt. In non-interactive environments, pruning only happens when `--prune` is passed explicitly **Output (stderr):** @@ -632,6 +640,7 @@ gh stack sync [flags] - `✓ Pushed N branches` - `✓ PR #N () — Open` per branch - `Merged: #N, #M` for merged branches +- `✓ Pruned (merged)` per pruned branch (when pruning) - `✓ Stack synced` --- From 3f3acde842968778ab5ccffb17b009740ba3e318 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 15 May 2026 12:50:50 -0400 Subject: [PATCH 7/8] addressing review comments --- cmd/sync.go | 12 ++++++++---- cmd/sync_test.go | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/cmd/sync.go b/cmd/sync.go index 94113c6..447f7be 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -41,7 +41,7 @@ conflicts interactively. Use --prune to delete local branches for merged PRs. Stack metadata is preserved so that rebase and display logic continue to work correctly. If you are on a branch that would be pruned, your checkout is moved to -the nearest active branch or the trunk.`, +the first active branch in the stack, or the trunk if all are merged.`, RunE: func(cmd *cobra.Command, args []string) error { return runSync(cfg, opts) }, @@ -417,9 +417,6 @@ func runSync(cfg *config.Config, opts *syncOptions) error { if err := git.DeleteBranch(name, true); err != nil { cfg.Warningf("Failed to delete %s: %v", name, err) } else { - // Also remove the remote-tracking ref so that - // `git checkout ` doesn't recreate the branch. - _ = git.DeleteTrackingRef(remote, name) cfg.Successf("Pruned %s (merged)", name) pruned++ } @@ -431,6 +428,13 @@ func runSync(cfg *config.Config, opts *syncOptions) error { cfg.Printf("") cfg.Printf("No merged branches to prune") } + + // Clean up remote-tracking refs for all merged branches, even if + // the local branch was already deleted. This prevents + // `git checkout ` from resurrecting the branch. + for _, b := range merged { + _ = git.DeleteTrackingRef(remote, b.Branch) + } } // --- Step 7: Update base SHAs and save --- diff --git a/cmd/sync_test.go b/cmd/sync_test.go index e25d239..6431ece 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -1035,6 +1035,7 @@ func TestSync_Prune_DeletesMergedBranches(t *testing.T) { writeStackFile(t, tmpDir, s) var deletedBranches []string + var deletedTrackingRefs []string mock := newSyncMock(tmpDir, "b2") mock.BranchExistsFn = func(name string) bool { return true } @@ -1043,6 +1044,10 @@ func TestSync_Prune_DeletesMergedBranches(t *testing.T) { assert.True(t, force, "should force-delete merged branch") return nil } + mock.DeleteTrackingRefFn = func(remote, branch string) error { + deletedTrackingRefs = append(deletedTrackingRefs, remote+"/"+branch) + return nil + } restore := git.SetOps(mock) defer restore() @@ -1060,6 +1065,7 @@ func TestSync_Prune_DeletesMergedBranches(t *testing.T) { assert.NoError(t, err) assert.Equal(t, []string{"b1"}, deletedBranches) + assert.Equal(t, []string{"origin/b1"}, deletedTrackingRefs, "should delete remote-tracking ref for pruned branch") assert.Contains(t, output, "Pruned b1 (merged)") assert.Contains(t, output, "Pruned 1 merged branch") } @@ -1087,6 +1093,12 @@ func TestSync_Prune_SkipsNonExistentBranches(t *testing.T) { return nil } + var deletedTrackingRefs []string + mock.DeleteTrackingRefFn = func(remote, branch string) error { + deletedTrackingRefs = append(deletedTrackingRefs, remote+"/"+branch) + return nil + } + restore := git.SetOps(mock) defer restore() @@ -1103,6 +1115,8 @@ func TestSync_Prune_SkipsNonExistentBranches(t *testing.T) { assert.NoError(t, err) assert.Contains(t, output, "No merged branches to prune") + // Tracking ref should still be cleaned up even though local branch is gone + assert.Equal(t, []string{"origin/b1"}, deletedTrackingRefs, "should delete tracking ref even when local branch is already gone") } // TestSync_Prune_SwitchesToLowestUnmergedBranch verifies that when the user is From 4e3421bcc17094cc3a0d3c9b2741edb2886d2e21 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 15 May 2026 13:08:13 -0400 Subject: [PATCH 8/8] increment skill file version --- skills/gh-stack/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 10c0c47..ded9150 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -7,7 +7,7 @@ description: > branch chains, or incremental code review workflows. metadata: author: github - version: "0.0.3" + version: "0.0.4" --- # gh-stack