Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
105 changes: 103 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 nearest active branch or the trunk.`,
Comment thread
skarim marked this conversation as resolved.
Outdated
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,91 @@ 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 {
// Also remove the remote-tracking ref so that
// `git checkout <name>` doesn't recreate the branch.
_ = git.DeleteTrackingRef(remote, name)
cfg.Successf("Pruned %s (merged)", name)
pruned++
Comment thread
skarim marked this conversation as resolved.
Outdated
}
}
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")
}
}

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

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