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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ This is the command to run when upstream changes have occurred (e.g., a PR in yo
| `-D, --dry-run` | Show what would be done |
| `-w, --worktrees` | Rebase branches checked out in linked worktrees in-place |
| `--no-update-refs` | Do not pass `--update-refs` to git (preserves untracked bookmark branches) |
| `-y, --yes` | Accept the default action (delete merged branches) without prompting |

### undo

Expand Down
14 changes: 9 additions & 5 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ var (
syncDryRunFlag bool
syncWorktreesFlag bool
syncNoUpdateRefsFlag bool
syncYesFlag bool
)

func init() {
syncCmd.Flags().BoolVar(&syncNoRestackFlag, "no-restack", false, "skip restacking branches")
syncCmd.Flags().BoolVarP(&syncDryRunFlag, "dry-run", "D", false, "show what would be done")
syncCmd.Flags().BoolVarP(&syncWorktreesFlag, "worktrees", "w", false, "rebase branches checked out in linked worktrees in-place")
syncCmd.Flags().BoolVar(&syncNoUpdateRefsFlag, "no-update-refs", false, "do not pass --update-refs to git (preserves untracked bookmark branches pointing into the stack)")
syncCmd.Flags().BoolVarP(&syncYesFlag, "yes", "y", false, "accept the default action (delete merged branches) without prompting")
rootCmd.AddCommand(syncCmd)
}

Expand Down Expand Up @@ -290,7 +292,7 @@ func runSync(cmd *cobra.Command, args []string) error {
if syncDryRunFlag {
fmt.Printf("%s Would handle merged branch %s\n", s.Muted("dry-run:"), s.Branch(branch))
} else {
action := handleMergedBranch(g, cfg, branch, trunk, &currentBranch, s)
action := handleMergedBranch(g, cfg, branch, trunk, &currentBranch, syncYesFlag, prompt.IsInteractive, s)
if action == mergedActionSkip {
// User chose to skip - don't collect fork points or retarget children
continue
Expand Down Expand Up @@ -438,12 +440,14 @@ const (

// handleMergedBranch prompts the user for how to handle a merged branch and executes the choice.
// Returns the action taken. If the user is on the merged branch, it will checkout trunk first.
// The currentBranch pointer is updated if a checkout occurs.
func handleMergedBranch(g *git.Git, cfg *config.Config, branch, trunk string, currentBranch *string, s *style.Style) mergedAction {
// The currentBranch pointer is updated if a checkout occurs. If yes is true, the default action
// (delete) is taken without prompting. isInteractive is injected so tests can force the
// interactive code path independently of terminal detection.
func handleMergedBranch(g *git.Git, cfg *config.Config, branch, trunk string, currentBranch *string, yes bool, isInteractive func() bool, s *style.Style) mergedAction {
fmt.Printf("\nBranch %s appears to be %s into %s.\n", s.Branch(branch), s.Merged("merged"), s.Branch(trunk))

// Default to delete in non-interactive mode
if !prompt.IsInteractive() {
// Default to delete when --yes is set or in non-interactive mode
if yes || !isInteractive() {
return deleteMergedBranch(g, cfg, branch, trunk, currentBranch, s)
}

Expand Down
120 changes: 120 additions & 0 deletions cmd/sync_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// cmd/sync_internal_test.go
//
// Internal tests (package cmd) for unexported sync helpers.
package cmd

import (
"os/exec"
"strings"
"testing"

"github.com/boneskull/gh-stack/internal/git"
"github.com/boneskull/gh-stack/internal/style"
)

// forceInteractive returns an isInteractive func that always returns true,
// allowing tests to exercise the interactive branch of handleMergedBranch
// regardless of whether stdin/stdout are a TTY.
func forceInteractive() func() bool { return func() bool { return true } }

// forceNonInteractive returns an isInteractive func that always returns false.
func forceNonInteractive() func() bool { return func() bool { return false } }

// TestHandleMergedBranchYesDeletesInInteractiveMode verifies that yes=true
// causes handleMergedBranch to delete without prompting even when the terminal
// is reported as interactive. Without the yes flag this would reach the prompt,
// so forcing isInteractive=true proves yes overrides the interactive branch.
func TestHandleMergedBranchYesDeletesInInteractiveMode(t *testing.T) {
cfg, dir := setupTestRepoWithDir(t)
g := git.New(dir)
s := style.New()

trunk := setupBranches(t, dir, cfg)

currentBranch := trunk
// isInteractive forced to true: without yes=true the function would reach
// the interactive prompt path and hang waiting for stdin input.
action := handleMergedBranch(g, cfg, "feature-a", trunk, &currentBranch, true, forceInteractive(), s)

if action != mergedActionDelete {
t.Errorf("expected mergedActionDelete with yes=true, got %v", action)
}

assertBranchDeleted(t, g, dir, "feature-a", cfg)
}

// TestHandleMergedBranchNoYesNonInteractiveDeletes verifies the existing
// non-interactive default: yes=false with a non-interactive terminal still
// deletes (no prompt).
func TestHandleMergedBranchNoYesNonInteractiveDeletes(t *testing.T) {
cfg, dir := setupTestRepoWithDir(t)
g := git.New(dir)
s := style.New()

trunk := setupBranches(t, dir, cfg)

currentBranch := trunk
action := handleMergedBranch(g, cfg, "feature-a", trunk, &currentBranch, false, forceNonInteractive(), s)

if action != mergedActionDelete {
t.Errorf("expected mergedActionDelete for non-interactive+yes=false, got %v", action)
}

assertBranchDeleted(t, g, dir, "feature-a", cfg)
}

// setupBranches creates a trunk+feature-a repo for handleMergedBranch tests and
// returns the trunk branch name. feature-a is registered in the stack with a
// commit so git will allow force-deletion.
func setupBranches(t *testing.T, dir string, cfg interface {
SetTrunk(string) error
SetParent(string, string) error
SetPR(string, int) error
}) string {
t.Helper()

out, err := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").Output()
if err != nil {
t.Fatalf("could not determine trunk branch: %v", err)
}
trunk := strings.TrimSpace(string(out))

if err := cfg.SetTrunk(trunk); err != nil {
t.Fatalf("SetTrunk failed: %v", err)
}
if err := exec.Command("git", "-C", dir, "checkout", "-b", "feature-a").Run(); err != nil {
t.Fatalf("create branch failed: %v", err)
}
if err := exec.Command("git", "-C", dir, "commit", "--allow-empty", "-m", "feat").Run(); err != nil {
t.Fatalf("commit failed: %v", err)
}
if err := exec.Command("git", "-C", dir, "checkout", trunk).Run(); err != nil {
t.Fatalf("checkout trunk failed: %v", err)
}
if err := cfg.SetParent("feature-a", trunk); err != nil {
t.Fatalf("SetParent failed: %v", err)
}
if err := cfg.SetPR("feature-a", 42); err != nil {
t.Fatalf("SetPR failed: %v", err)
}
return trunk
}

// assertBranchDeleted checks that the git branch no longer exists and that
// stackParent / stackPR have been cleared from config.
func assertBranchDeleted(t *testing.T, g *git.Git, dir string, branch string, cfg interface {
GetParent(string) (string, error)
GetPR(string) (int, error)
}) {
t.Helper()

if g.BranchExists(branch) {
t.Errorf("expected branch %q to be deleted, but it still exists", branch)
}
if _, err := cfg.GetParent(branch); err == nil {
t.Errorf("expected stackParent to be removed after delete, but it is still set for %q", branch)
}
if _, err := cfg.GetPR(branch); err == nil {
t.Errorf("expected stackPR to be removed after delete, but it is still set for %q", branch)
}
}
11 changes: 11 additions & 0 deletions e2e/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,14 @@ func TestSyncAcceptsDryRunFlag(t *testing.T) {
t.Errorf("expected sync --help to show --dry-run flag, got:\n%s", result.Stdout)
}
}

func TestSyncAcceptsYesFlag(t *testing.T) {
// Verify --yes / -y flag is recognized by sync
env := NewTestEnv(t)
env.MustRun("init")

result := env.Run("sync", "--help")
if !result.ContainsStdout("--yes") {
t.Errorf("expected sync --help to show --yes flag, got:\n%s", result.Stdout)
}
}
Loading