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
2 changes: 1 addition & 1 deletion cmd/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func runNew(gitClient git.GitClient, branchName string, explicitParent string) e
fmt.Printf("Creating new branch %s from %s\n", branchName, parent)

// Create the new branch
if err := gitClient.CreateBranch(branchName, parent); err != nil {
if err := gitClient.CreateBranchAndCheckout(branchName, parent); err != nil {
return fmt.Errorf("failed to create branch: %w", err)
}

Expand Down
15 changes: 7 additions & 8 deletions cmd/new_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestRunNew(t *testing.T) {
// Parent exists
mockGit.On("BranchExists", "feature-a").Return(true)
// Create branch
mockGit.On("CreateBranch", "feature-b", "feature-a").Return(nil)
mockGit.On("CreateBranchAndCheckout", "feature-b", "feature-a").Return(nil)
// Set config
mockGit.On("SetConfig", "branch.feature-b.stackparent", "feature-a").Return(nil)
},
Expand All @@ -47,7 +47,7 @@ func TestRunNew(t *testing.T) {
// Check if current branch has parent
mockGit.On("GetConfig", "branch.feature-a.stackparent").Return("main")
// Create branch from current
mockGit.On("CreateBranch", "feature-b", "feature-a").Return(nil)
mockGit.On("CreateBranchAndCheckout", "feature-b", "feature-a").Return(nil)
// Set config
mockGit.On("SetConfig", "branch.feature-b.stackparent", "feature-a").Return(nil)
},
Expand Down Expand Up @@ -148,7 +148,7 @@ func TestRunNewSetConfig(t *testing.T) {
// Parent exists
mockGit.On("BranchExists", "parent-branch").Return(true)
// Create branch
mockGit.On("CreateBranch", "new-branch", "parent-branch").Return(nil)
mockGit.On("CreateBranchAndCheckout", "new-branch", "parent-branch").Return(nil)
// Verify SetConfig is called with correct parameters
mockGit.On("SetConfig", "branch.new-branch.stackparent", "parent-branch").Return(nil)

Expand All @@ -173,7 +173,7 @@ func TestRunNewFromCurrentBranch(t *testing.T) {
// Current branch has a parent (it's in a stack)
mockGit.On("GetConfig", "branch.current-branch.stackparent").Return("main")
// Create branch from current
mockGit.On("CreateBranch", "new-branch", "current-branch").Return(nil)
mockGit.On("CreateBranchAndCheckout", "new-branch", "current-branch").Return(nil)
// Set config
mockGit.On("SetConfig", "branch.new-branch.stackparent", "current-branch").Return(nil)

Expand All @@ -189,12 +189,12 @@ func TestRunNewErrorHandling(t *testing.T) {
testutil.SetupTest()
defer testutil.TeardownTest()

t.Run("error on CreateBranch failure", func(t *testing.T) {
t.Run("error on CreateBranchAndCheckout failure", func(t *testing.T) {
mockGit := new(testutil.MockGitClient)

mockGit.On("BranchExists", "new-branch").Return(false)
mockGit.On("BranchExists", "parent").Return(true)
mockGit.On("CreateBranch", "new-branch", "parent").Return(fmt.Errorf("git error"))
mockGit.On("CreateBranchAndCheckout", "new-branch", "parent").Return(fmt.Errorf("git error"))

err := runNew(mockGit, "new-branch", "parent")

Expand All @@ -209,7 +209,7 @@ func TestRunNewErrorHandling(t *testing.T) {

mockGit.On("BranchExists", "new-branch").Return(false)
mockGit.On("BranchExists", "parent").Return(true)
mockGit.On("CreateBranch", "new-branch", "parent").Return(nil)
mockGit.On("CreateBranchAndCheckout", "new-branch", "parent").Return(nil)
mockGit.On("SetConfig", "branch.new-branch.stackparent", "parent").Return(fmt.Errorf("config error"))

err := runNew(mockGit, "new-branch", "parent")
Expand All @@ -220,4 +220,3 @@ func TestRunNewErrorHandling(t *testing.T) {
mockGit.AssertExpectations(t)
})
}

78 changes: 74 additions & 4 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ import (
var errAlreadyPrinted = errors.New("")

var (
syncForce bool
syncResume bool
syncAbort bool
syncForce bool
syncResume bool
syncAbort bool
syncCherryPick bool
// stdinReader allows tests to inject mock input for prompts
stdinReader io.Reader = os.Stdin
)
Expand Down Expand Up @@ -87,6 +88,7 @@ func init() {
syncCmd.Flags().BoolVarP(&syncForce, "force", "f", false, "Use --force instead of --force-with-lease for push (bypasses safety checks)")
syncCmd.Flags().BoolVarP(&syncResume, "resume", "r", false, "Resume a sync after resolving rebase conflicts")
syncCmd.Flags().BoolVarP(&syncAbort, "abort", "a", false, "Abort an interrupted sync and clean up state")
syncCmd.Flags().BoolVar(&syncCherryPick, "cherry-pick", false, "Rebuild polluted branches by cherry-picking unique commits (creates backup)")
}

func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error {
Expand Down Expand Up @@ -598,6 +600,71 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error {
if err == nil && len(allCommits) > len(uniqueCommits)*2 {
// Branch has polluted history: many more commits than unique patches
// This usually means branch diverged from parent's history (e.g., based on old backup)

if syncCherryPick {
// Automated cherry-pick rebuild with backup
backupBranch := branch.Name + "-backup"
tempBranch := branch.Name + "-rebuild"

fmt.Printf("\n")
fmt.Printf("⚠ Detected polluted branch history (%d commits, %d unique patches)\n", len(allCommits), len(uniqueCommits))
fmt.Printf(" Creating backup: %s\n", backupBranch)

// Create backup branch from current branch (without checkout)
if err := gitClient.CreateBranch(backupBranch, branch.Name); err != nil {
return fmt.Errorf("failed to create backup branch: %w", err)
}

fmt.Printf(" Rebuilding with %d unique commit(s)...\n", len(uniqueCommits))

// Checkout parent branch
if err := gitClient.CheckoutBranch(rebaseTarget); err != nil {
return fmt.Errorf("failed to checkout parent %s: %w", rebaseTarget, err)
}

// Create temp branch from parent
if err := gitClient.CreateBranchAndCheckout(tempBranch, rebaseTarget); err != nil {
return fmt.Errorf("failed to create temp branch: %w", err)
}

// Cherry-pick each unique commit
for _, commit := range uniqueCommits {
if git.Verbose {
fmt.Printf(" Cherry-picking %s\n", commit[:8])
}
if err := gitClient.CherryPick(commit); err != nil {
// Cherry-pick conflict - let user resolve
rebaseConflict = true
fmt.Fprintf(os.Stderr, "\n Cherry-pick conflict on %s. To continue:\n", commit[:8])
fmt.Fprintf(os.Stderr, " 1. Resolve the conflicts\n")
fmt.Fprintf(os.Stderr, " 2. Run 'git add <resolved files>'\n")
fmt.Fprintf(os.Stderr, " 3. Run 'git cherry-pick --continue'\n")
fmt.Fprintf(os.Stderr, " 4. Complete remaining cherry-picks manually\n")
fmt.Fprintf(os.Stderr, " 5. Run 'git branch -D %s && git branch -m %s'\n", branch.Name, branch.Name)
fmt.Fprintf(os.Stderr, " 6. Run 'stack sync --resume'\n")
fmt.Fprintf(os.Stderr, "\n Backup saved as: %s\n", backupBranch)
return fmt.Errorf("cherry-pick conflict: %w", err)
}
}

// Delete original branch and rename temp to original
if err := gitClient.DeleteBranchForce(branch.Name); err != nil {
return fmt.Errorf("failed to delete original branch: %w", err)
}

// We're on tempBranch, rename it to the original branch name
if err := gitClient.RenameBranch(tempBranch, branch.Name); err != nil {
return fmt.Errorf("failed to rename temp branch: %w", err)
}

fmt.Printf("✓ Rebuilt %s (backup saved as %s)\n", branch.Name, backupBranch)
fmt.Printf(" To delete backup later: git branch -D %s\n", backupBranch)

// Branch is now clean - no need to rebase, just return nil
return nil
}

// No --cherry-pick flag: show warning and suggest the flag
rebaseConflict = true
fmt.Fprintf(os.Stderr, "\n")
fmt.Fprintf(os.Stderr, "⚠ Detected polluted branch history:\n")
Expand All @@ -607,7 +674,10 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error {
fmt.Fprintf(os.Stderr, "This usually means your branch diverged from the parent's history.\n")
fmt.Fprintf(os.Stderr, "Rebasing may result in many conflicts.\n")
fmt.Fprintf(os.Stderr, "\n")
fmt.Fprintf(os.Stderr, "Recommended: Rebuild branch manually with cherry-pick:\n")
fmt.Fprintf(os.Stderr, "Recommended: Run 'stack sync --cherry-pick' to auto-rebuild\n")
fmt.Fprintf(os.Stderr, " (Creates backup branch before rebuilding)\n")
fmt.Fprintf(os.Stderr, "\n")
fmt.Fprintf(os.Stderr, "Or rebuild manually:\n")
fmt.Fprintf(os.Stderr, " 1. git checkout %s\n", branch.Parent)
fmt.Fprintf(os.Stderr, " 2. git checkout -b %s-clean\n", branch.Name)
for i, commit := range uniqueCommits {
Expand Down
12 changes: 11 additions & 1 deletion internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,18 @@ func (c *gitClient) UnsetConfig(key string) error {
return err
}

// CreateBranch creates a new branch from the specified base and checks it out
// CreateBranch creates a new branch from a ref without checking it out
func (c *gitClient) CreateBranch(name, from string) error {
if DryRun {
fmt.Printf(" [DRY RUN] git branch %s %s\n", name, from)
return nil
}
_, err := c.runCmd("branch", name, from)
return err
}

// CreateBranchAndCheckout creates a new branch from the specified base and checks it out
func (c *gitClient) CreateBranchAndCheckout(name, from string) error {
if DryRun {
fmt.Printf(" [DRY RUN] git checkout -b %s %s\n", name, from)
return nil
Expand Down
1 change: 1 addition & 0 deletions internal/git/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type GitClient interface {
SetConfig(key, value string) error
UnsetConfig(key string) error
CreateBranch(name, from string) error
CreateBranchAndCheckout(name, from string) error
CheckoutBranch(name string) error
RenameBranch(oldName, newName string) error
Rebase(onto string) error
Expand Down
5 changes: 5 additions & 0 deletions internal/testutil/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ func (m *MockGitClient) CreateBranch(name, from string) error {
return args.Error(0)
}

func (m *MockGitClient) CreateBranchAndCheckout(name, from string) error {
args := m.Called(name, from)
return args.Error(0)
}

func (m *MockGitClient) CheckoutBranch(name string) error {
args := m.Called(name)
return args.Error(0)
Expand Down