Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` | 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`
Expand Down Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/submit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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) {
Expand Down
109 changes: 107 additions & 2 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -14,6 +15,7 @@ import (

type syncOptions struct {
remote string
prune bool
}

func SyncCmd(cfg *config.Config) *cobra.Command {
Expand All @@ -34,13 +36,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 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)
},
}

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
}
Expand Down Expand Up @@ -341,7 +349,95 @@ 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) ---
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 {
if git.BranchExists(b.Branch) {
prunable = append(prunable, b.Branch)
}
}
Comment thread
skarim marked this conversation as resolved.

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 if opts.prune {
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 <name>` from resurrecting the branch.
for _, b := range merged {
_ = git.DeleteTrackingRef(remote, b.Branch)
}
}

// --- Step 7: Update base SHAs and save ---
updateBaseSHAs(s)

if err := stack.Save(gitDir, sf); err != nil {
Expand Down Expand Up @@ -392,3 +488,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)
}
Loading
Loading