From dac656480e81f07f02f5f5f53ffd7a2ef983387e Mon Sep 17 00:00:00 2001 From: Jonatan Dahl Date: Tue, 9 Dec 2025 17:17:01 -0500 Subject: [PATCH 1/5] feat: detect polluted branch history and guide manual cleanup - Add patch-based comparison using git cherry to detect duplicate commits - Detect when branch history diverged from parent (many commits but few unique patches) - Provide clear manual cleanup instructions instead of automatic destructive operations - Add IsRebaseInProgress() and IsCherryPickInProgress() to detect git state - Add AbortCherryPick() support for stack sync --abort - Simplify rebase logic by removing complex base branch filtering This prevents data loss when branches have diverged history and provides users with safe, actionable steps to rebuild branches manually. --- cmd/sync.go | 112 ++++++++++++++++++++++++++++++++++---- internal/git/git.go | 86 +++++++++++++++++++++++++++++ internal/git/interface.go | 7 +++ 3 files changed, 195 insertions(+), 10 deletions(-) diff --git a/cmd/sync.go b/cmd/sync.go index 8228317..99344cb 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -98,22 +98,37 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { hasSavedState := savedStashed == "true" || savedOriginalBranch != "" if syncAbort { - // Aborting an interrupted sync - if !hasSavedState { + // Check if there's actually anything to abort + hasCherryPick := gitClient.IsCherryPickInProgress() + hasRebase := gitClient.IsRebaseInProgress() + + if !hasSavedState && !hasCherryPick && !hasRebase { return fmt.Errorf("no interrupted sync to abort\n\nUse 'stack sync' to start a new sync") } fmt.Println("Aborting sync and cleaning up...") fmt.Println() - // Try to abort rebase if one is in progress - // It's okay if this fails - there might not be an active rebase - if err := gitClient.AbortRebase(); err != nil { - if git.Verbose { - fmt.Fprintf(os.Stderr, "Note: no rebase to abort (already resolved or not started)\n") + // Abort cherry-pick if one is in progress + if hasCherryPick { + if err := gitClient.AbortCherryPick(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to abort cherry-pick: %v\n", err) + } else { + fmt.Println("✓ Aborted cherry-pick") } - } else { - fmt.Println("✓ Aborted rebase") + } else if git.Verbose { + fmt.Fprintf(os.Stderr, "Note: no cherry-pick in progress\n") + } + + // Abort rebase if one is in progress + if hasRebase { + if err := gitClient.AbortRebase(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to abort rebase: %v\n", err) + } else { + fmt.Println("✓ Aborted rebase") + } + } else if git.Verbose { + fmt.Fprintf(os.Stderr, "Note: no rebase in progress\n") } // Restore stashed changes if any @@ -509,7 +524,84 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { fmt.Printf(" Using --onto to handle squash merge (excluding commits from %s)\n", oldParent) return gitClient.RebaseOnto(rebaseTarget, oldParent, branch.Name) } - return gitClient.Rebase(rebaseTarget) + + // Get unique commits in this branch by comparing patch content (not just SHAs) + // This detects duplicate changes even if commits were rebased with different SHAs + uniqueCommits, err := gitClient.GetUniqueCommitsByPatch(rebaseTarget, branch.Name) + if err != nil { + // If we can't get unique commits, fall back to regular rebase + if git.Verbose { + fmt.Printf(" Could not get unique commits by patch, using regular rebase: %v\n", err) + } + return gitClient.Rebase(rebaseTarget) + } + + // If no unique commits, branch is up-to-date + if len(uniqueCommits) == 0 { + if git.Verbose { + fmt.Printf(" Branch is up-to-date with %s (no unique patches)\n", rebaseTarget) + } + return nil + } + + if git.Verbose { + fmt.Printf(" Found %d unique commit(s) by patch comparison\n", len(uniqueCommits)) + } + + // Get merge-base to understand the history + mergeBase, err := gitClient.GetMergeBase(branch.Name, rebaseTarget) + if err != nil { + // If we can't find merge-base, fall back to regular rebase + if git.Verbose { + fmt.Printf(" Could not find merge-base, using regular rebase: %v\n", err) + } + return gitClient.Rebase(rebaseTarget) + } + + rebaseTargetHash, err := gitClient.GetCommitHash(rebaseTarget) + if err == nil && mergeBase == rebaseTargetHash { + // Parent hasn't changed since we branched, regular rebase is fine + return gitClient.Rebase(rebaseTarget) + } + + // Count commits from merge-base to current branch (total commits in branch history) + allCommits, err := gitClient.GetUniqueCommits(mergeBase, branch.Name) + 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) + rebaseConflict = true + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, "⚠ Detected polluted branch history:\n") + fmt.Fprintf(os.Stderr, " - %d commits in branch history\n", len(allCommits)) + fmt.Fprintf(os.Stderr, " - Only %d unique patch(es)\n", len(uniqueCommits)) + fmt.Fprintf(os.Stderr, "\n") + 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, " 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 { + if i < 5 { // Show first 5 commits + fmt.Fprintf(os.Stderr, " 3. git cherry-pick %s\n", commit[:8]) + } + } + if len(uniqueCommits) > 5 { + fmt.Fprintf(os.Stderr, " ... (%d more commits)\n", len(uniqueCommits)-5) + } + fmt.Fprintf(os.Stderr, " 4. git branch -D %s\n", branch.Name) + fmt.Fprintf(os.Stderr, " 5. git branch -m %s\n", branch.Name) + fmt.Fprintf(os.Stderr, " 6. git push --force-with-lease\n") + fmt.Fprintf(os.Stderr, "\n") + return fmt.Errorf("branch history is polluted, manual cleanup recommended") + } + + // Use --onto to only replay commits unique to this branch + // This prevents conflicts from duplicate commits when parent was rebased + if git.Verbose { + fmt.Printf(" Using --onto with merge-base %s to handle rebased parent\n", mergeBase[:8]) + } + return gitClient.RebaseOnto(rebaseTarget, mergeBase, branch.Name) }, ); err != nil { rebaseConflict = true diff --git a/internal/git/git.go b/internal/git/git.go index a467d45..8028e16 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -300,6 +300,20 @@ func (c *gitClient) GetRemoteBranchesSet() map[string]bool { return branches } +// IsRebaseInProgress checks if a rebase is currently in progress +func (c *gitClient) IsRebaseInProgress() bool { + // Git creates REBASE_HEAD during rebase (both regular and interactive) + _, err := c.runCmd("rev-parse", "--verify", "REBASE_HEAD") + return err == nil +} + +// IsCherryPickInProgress checks if a cherry-pick is currently in progress +func (c *gitClient) IsCherryPickInProgress() bool { + // Git creates .git/CHERRY_PICK_HEAD during cherry-pick + _, err := c.runCmd("rev-parse", "--verify", "CHERRY_PICK_HEAD") + return err == nil +} + // AbortRebase aborts an in-progress rebase func (c *gitClient) AbortRebase() error { if DryRun { @@ -310,6 +324,16 @@ func (c *gitClient) AbortRebase() error { return err } +// AbortCherryPick aborts an in-progress cherry-pick +func (c *gitClient) AbortCherryPick() error { + if DryRun { + fmt.Printf(" [DRY RUN] git cherry-pick --abort\n") + return nil + } + _, err := c.runCmd("cherry-pick", "--abort") + return err +} + // ResetToRemote resets the current branch to match the remote branch exactly func (c *gitClient) ResetToRemote(branch string) error { remoteBranch := "origin/" + branch @@ -331,6 +355,68 @@ func (c *gitClient) GetCommitHash(ref string) (string, error) { return c.runCmd("rev-parse", ref) } +// GetUniqueCommits returns the list of commits in branch that are not in base +// Returns commit hashes in reverse chronological order (newest first) +func (c *gitClient) GetUniqueCommits(base, branch string) ([]string, error) { + output, err := c.runCmd("rev-list", "--reverse", base+".."+branch) + if err != nil { + return nil, err + } + if output == "" { + return []string{}, nil + } + return strings.Split(output, "\n"), nil +} + +// GetUniqueCommitsByPatch returns commits in branch that are not in base by comparing patch content +// This uses git-cherry which compares patch IDs rather than commit SHAs, so it detects +// duplicate changes even if commits were rebased (different SHAs but same content) +// Returns commit hashes of truly unique patches +func (c *gitClient) GetUniqueCommitsByPatch(base, branch string) ([]string, error) { + // git cherry outputs: "+ " for unique commits, "- " for duplicates + output, err := c.runCmd("cherry", base, branch) + if err != nil { + return nil, err + } + if output == "" { + return []string{}, nil + } + + var uniqueCommits []string + lines := strings.Split(output, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "+") { + // Extract SHA (format is "+ ") + parts := strings.Fields(line) + if len(parts) >= 2 { + uniqueCommits = append(uniqueCommits, parts[1]) + } + } + } + return uniqueCommits, nil +} + +// CherryPick cherry-picks a commit onto the current branch +func (c *gitClient) CherryPick(commit string) error { + if DryRun { + fmt.Printf(" [DRY RUN] git cherry-pick %s\n", commit) + return nil + } + _, err := c.runCmd("cherry-pick", commit) + return err +} + +// ResetHard resets the current branch to a ref +func (c *gitClient) ResetHard(ref string) error { + if DryRun { + fmt.Printf(" [DRY RUN] git reset --hard %s\n", ref) + return nil + } + _, err := c.runCmd("reset", "--hard", ref) + return err +} + // Stash stashes the current changes func (c *gitClient) Stash(message string) error { if DryRun { diff --git a/internal/git/interface.go b/internal/git/interface.go index 4605ecb..07f5334 100644 --- a/internal/git/interface.go +++ b/internal/git/interface.go @@ -23,10 +23,17 @@ type GitClient interface { BranchExists(name string) bool RemoteBranchExists(name string) bool GetRemoteBranchesSet() map[string]bool + IsRebaseInProgress() bool + IsCherryPickInProgress() bool AbortRebase() error + AbortCherryPick() error ResetToRemote(branch string) error GetMergeBase(branch1, branch2 string) (string, error) GetCommitHash(ref string) (string, error) + GetUniqueCommits(base, branch string) ([]string, error) + GetUniqueCommitsByPatch(base, branch string) ([]string, error) + CherryPick(commit string) error + ResetHard(ref string) error Stash(message string) error StashPop() error GetDefaultBranch() string From a0d402c254beb6bceaab92792e95cabcc5980283 Mon Sep 17 00:00:00 2001 From: Jonatan Dahl Date: Wed, 10 Dec 2025 10:36:54 -0500 Subject: [PATCH 2/5] test: update MockGitClient with new interface methods Add mock implementations for: - GetUniqueCommits - GetUniqueCommitsByPatch - CherryPick - ResetHard - IsRebaseInProgress - IsCherryPickInProgress - AbortCherryPick --- internal/testutil/mocks.go | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/internal/testutil/mocks.go b/internal/testutil/mocks.go index e01e288..2cde3de 100644 --- a/internal/testutil/mocks.go +++ b/internal/testutil/mocks.go @@ -115,11 +115,26 @@ func (m *MockGitClient) GetRemoteBranchesSet() map[string]bool { return args.Get(0).(map[string]bool) } +func (m *MockGitClient) IsRebaseInProgress() bool { + args := m.Called() + return args.Bool(0) +} + +func (m *MockGitClient) IsCherryPickInProgress() bool { + args := m.Called() + return args.Bool(0) +} + func (m *MockGitClient) AbortRebase() error { args := m.Called() return args.Error(0) } +func (m *MockGitClient) AbortCherryPick() error { + args := m.Called() + return args.Error(0) +} + func (m *MockGitClient) ResetToRemote(branch string) error { args := m.Called(branch) return args.Error(0) @@ -135,6 +150,32 @@ func (m *MockGitClient) GetCommitHash(ref string) (string, error) { return args.String(0), args.Error(1) } +func (m *MockGitClient) GetUniqueCommits(base, branch string) ([]string, error) { + args := m.Called(base, branch) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]string), args.Error(1) +} + +func (m *MockGitClient) GetUniqueCommitsByPatch(base, branch string) ([]string, error) { + args := m.Called(base, branch) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]string), args.Error(1) +} + +func (m *MockGitClient) CherryPick(commit string) error { + args := m.Called(commit) + return args.Error(0) +} + +func (m *MockGitClient) ResetHard(ref string) error { + args := m.Called(ref) + return args.Error(0) +} + func (m *MockGitClient) Stash(message string) error { args := m.Called(message) return args.Error(0) From 9b46304f7b1affa0dc7990b289c0be51a71ab149 Mon Sep 17 00:00:00 2001 From: Jonatan Dahl Date: Wed, 10 Dec 2025 10:49:35 -0500 Subject: [PATCH 3/5] test: add mock expectations for patch-based comparison Update sync tests with mock expectations for: - GetUniqueCommitsByPatch - GetMergeBase - GetCommitHash (for parent comparison) --- cmd/sync_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 96cb8ba..f9aef86 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -55,6 +55,11 @@ func TestRunSyncBasic(t *testing.T) { mockGit.On("CheckoutBranch", "feature-a").Return(nil) mockGit.On("GetCommitHash", "feature-a").Return("abc123", nil) mockGit.On("GetCommitHash", "origin/feature-a").Return("abc123", nil) + // Patch-based unique commit detection + mockGit.On("GetUniqueCommitsByPatch", "origin/main", "feature-a").Return([]string{"abc123"}, nil) + mockGit.On("GetMergeBase", "feature-a", "origin/main").Return("main123", nil) + mockGit.On("GetCommitHash", "origin/main").Return("main123", nil) + // Falls through to regular rebase since merge-base == parent mockGit.On("Rebase", "origin/main").Return(nil) mockGit.On("FetchBranch", "feature-a").Return(nil) mockGit.On("PushWithExpectedRemote", "feature-a", "abc123").Return(nil) @@ -62,6 +67,11 @@ func TestRunSyncBasic(t *testing.T) { mockGit.On("CheckoutBranch", "feature-b").Return(nil) mockGit.On("GetCommitHash", "feature-b").Return("def456", nil) mockGit.On("GetCommitHash", "origin/feature-b").Return("def456", nil) + // Patch-based unique commit detection + mockGit.On("GetUniqueCommitsByPatch", "feature-a", "feature-b").Return([]string{"def456"}, nil) + mockGit.On("GetMergeBase", "feature-b", "feature-a").Return("abc123", nil) + mockGit.On("GetCommitHash", "feature-a").Return("abc123", nil) + // Falls through to regular rebase since merge-base == parent mockGit.On("Rebase", "feature-a").Return(nil) mockGit.On("FetchBranch", "feature-b").Return(nil) mockGit.On("PushWithExpectedRemote", "feature-b", "def456").Return(nil) @@ -197,6 +207,9 @@ func TestRunSyncUpdatePRBase(t *testing.T) { mockGit.On("CheckoutBranch", "feature-a").Return(nil) mockGit.On("GetCommitHash", "feature-a").Return("abc123", nil) mockGit.On("GetCommitHash", "origin/feature-a").Return("abc123", nil) + mockGit.On("GetUniqueCommitsByPatch", "origin/main", "feature-a").Return([]string{"abc123"}, nil) + mockGit.On("GetMergeBase", "feature-a", "origin/main").Return("main123", nil) + mockGit.On("GetCommitHash", "origin/main").Return("main123", nil) mockGit.On("Rebase", "origin/main").Return(nil) mockGit.On("FetchBranch", "feature-a").Return(nil) mockGit.On("PushWithExpectedRemote", "feature-a", "abc123").Return(nil) @@ -205,6 +218,9 @@ func TestRunSyncUpdatePRBase(t *testing.T) { mockGit.On("CheckoutBranch", "feature-b").Return(nil) mockGit.On("GetCommitHash", "feature-b").Return("def456", nil) mockGit.On("GetCommitHash", "origin/feature-b").Return("def456", nil) + mockGit.On("GetUniqueCommitsByPatch", "feature-a", "feature-b").Return([]string{"def456"}, nil) + mockGit.On("GetMergeBase", "feature-b", "feature-a").Return("abc123", nil) + mockGit.On("GetCommitHash", "feature-a").Return("abc123", nil) mockGit.On("Rebase", "feature-a").Return(nil) mockGit.On("FetchBranch", "feature-b").Return(nil) mockGit.On("PushWithExpectedRemote", "feature-b", "def456").Return(nil) @@ -271,6 +287,9 @@ func TestRunSyncStashHandling(t *testing.T) { mockGit.On("CheckoutBranch", "feature-a").Return(nil) mockGit.On("GetCommitHash", "feature-a").Return("abc123", nil) mockGit.On("GetCommitHash", "origin/feature-a").Return("abc123", nil) + mockGit.On("GetUniqueCommitsByPatch", "origin/main", "feature-a").Return([]string{"abc123"}, nil) + mockGit.On("GetMergeBase", "feature-a", "origin/main").Return("main123", nil) + mockGit.On("GetCommitHash", "origin/main").Return("main123", nil) mockGit.On("Rebase", "origin/main").Return(nil) mockGit.On("FetchBranch", "feature-a").Return(nil) mockGit.On("PushWithExpectedRemote", "feature-a", "abc123").Return(nil) @@ -328,6 +347,9 @@ func TestRunSyncErrorHandling(t *testing.T) { mockGit.On("CheckoutBranch", "feature-a").Return(nil) mockGit.On("GetCommitHash", "feature-a").Return("abc123", nil) mockGit.On("GetCommitHash", "origin/feature-a").Return("abc123", nil) + mockGit.On("GetUniqueCommitsByPatch", "origin/main", "feature-a").Return([]string{"abc123"}, nil) + mockGit.On("GetMergeBase", "feature-a", "origin/main").Return("main123", nil) + mockGit.On("GetCommitHash", "origin/main").Return("main123", nil) // Rebase fails mockGit.On("Rebase", "origin/main").Return(fmt.Errorf("rebase conflict")) // Note: StashPop is NOT called because rebaseConflict=true @@ -378,6 +400,9 @@ func TestRunSyncErrorHandling(t *testing.T) { mockGit.On("CheckoutBranch", "feature-a").Return(nil) mockGit.On("GetCommitHash", "feature-a").Return("abc123", nil) mockGit.On("GetCommitHash", "origin/feature-a").Return("abc123", nil) + mockGit.On("GetUniqueCommitsByPatch", "origin/main", "feature-a").Return([]string{"abc123"}, nil) + mockGit.On("GetMergeBase", "feature-a", "origin/main").Return("main123", nil) + mockGit.On("GetCommitHash", "origin/main").Return("main123", nil) // Rebase fails - stash should NOT be popped (preserved for --resume) mockGit.On("Rebase", "origin/main").Return(fmt.Errorf("rebase conflict")) // Note: StashPop is NOT called because rebaseConflict=true @@ -520,6 +545,9 @@ func TestRunSyncResume(t *testing.T) { mockGit.On("CheckoutBranch", "feature-a").Return(nil) mockGit.On("GetCommitHash", "feature-a").Return("abc123", nil) mockGit.On("GetCommitHash", "origin/feature-a").Return("abc123", nil) + mockGit.On("GetUniqueCommitsByPatch", "origin/main", "feature-a").Return([]string{"abc123"}, nil) + mockGit.On("GetMergeBase", "feature-a", "origin/main").Return("main123", nil) + mockGit.On("GetCommitHash", "origin/main").Return("main123", nil) mockGit.On("Rebase", "origin/main").Return(nil) mockGit.On("FetchBranch", "feature-a").Return(nil) mockGit.On("PushWithExpectedRemote", "feature-a", "abc123").Return(nil) @@ -579,6 +607,9 @@ func TestRunSyncResume(t *testing.T) { mockGit.On("CheckoutBranch", "feature-a").Return(nil) mockGit.On("GetCommitHash", "feature-a").Return("abc123", nil) mockGit.On("GetCommitHash", "origin/feature-a").Return("abc123", nil) + mockGit.On("GetUniqueCommitsByPatch", "origin/main", "feature-a").Return([]string{"abc123"}, nil) + mockGit.On("GetMergeBase", "feature-a", "origin/main").Return("main123", nil) + mockGit.On("GetCommitHash", "origin/main").Return("main123", nil) mockGit.On("Rebase", "origin/main").Return(nil) mockGit.On("FetchBranch", "feature-a").Return(nil) mockGit.On("PushWithExpectedRemote", "feature-a", "abc123").Return(nil) From f1eecb9be9786ed67d558e6d6763b974066fb08c Mon Sep 17 00:00:00 2001 From: Jonatan Dahl Date: Wed, 10 Dec 2025 10:55:06 -0500 Subject: [PATCH 4/5] spacing --- cmd/sync_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/sync_test.go b/cmd/sync_test.go index f9aef86..d7fe799 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -419,7 +419,7 @@ func TestFilterMergedBranchesForSync(t *testing.T) { // Test the filterMergedBranchesForSync function // This is a simple unit test for the tree filtering logic prCache := map[string]*github.PRInfo{ - "merged-leaf": testutil.NewPRInfo(1, "MERGED", "main", "Merged Leaf", "url"), + "merged-leaf": testutil.NewPRInfo(1, "MERGED", "main", "Merged Leaf", "url"), "merged-parent": testutil.NewPRInfo(2, "MERGED", "main", "Merged Parent", "url"), } From e1f20afdfec4babd835cd3152bdf8002129ddaa0 Mon Sep 17 00:00:00 2001 From: Jonatan Dahl Date: Wed, 10 Dec 2025 11:29:20 -0500 Subject: [PATCH 5/5] fix: add missing mock expectations for IsCherryPickInProgress and IsRebaseInProgress --- cmd/sync_test.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/cmd/sync_test.go b/cmd/sync_test.go index d7fe799..8f5505f 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -640,6 +640,10 @@ func TestRunSyncAbort(t *testing.T) { mockGit.On("GetConfig", "stack.sync.stashed").Return("") mockGit.On("GetConfig", "stack.sync.originalBranch").Return("") + // No rebase or cherry-pick in progress + mockGit.On("IsCherryPickInProgress").Return(false) + mockGit.On("IsRebaseInProgress").Return(false) + // Set abort flag syncAbort = true defer func() { syncAbort = false }() @@ -658,6 +662,10 @@ func TestRunSyncAbort(t *testing.T) { mockGit.On("GetConfig", "stack.sync.stashed").Return("true") mockGit.On("GetConfig", "stack.sync.originalBranch").Return("feature-a") + // Rebase in progress (to trigger AbortRebase) + mockGit.On("IsCherryPickInProgress").Return(false) + mockGit.On("IsRebaseInProgress").Return(true) + // Set abort flag syncAbort = true defer func() { syncAbort = false }() @@ -688,6 +696,10 @@ func TestRunSyncAbort(t *testing.T) { mockGit.On("GetConfig", "stack.sync.stashed").Return("") mockGit.On("GetConfig", "stack.sync.originalBranch").Return("feature-a") + // Rebase in progress (to trigger AbortRebase) + mockGit.On("IsCherryPickInProgress").Return(false) + mockGit.On("IsRebaseInProgress").Return(true) + // Set abort flag syncAbort = true defer func() { syncAbort = false }() @@ -717,11 +729,15 @@ func TestRunSyncAbort(t *testing.T) { mockGit.On("GetConfig", "stack.sync.stashed").Return("") mockGit.On("GetConfig", "stack.sync.originalBranch").Return("feature-a") + // Rebase in progress (to trigger AbortRebase) + mockGit.On("IsCherryPickInProgress").Return(false) + mockGit.On("IsRebaseInProgress").Return(true) + // Set abort flag syncAbort = true defer func() { syncAbort = false }() - // Abort rebase fails (no rebase in progress) + // Abort rebase fails (simulated failure) mockGit.On("AbortRebase").Return(fmt.Errorf("no rebase in progress")) // Return to original branch mockGit.On("GetCurrentBranch").Return("feature-a", nil)