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
43 changes: 23 additions & 20 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Uncommitted changes are automatically stashed and reapplied (using --autostash).
}

func init() {
syncCmd.Flags().BoolVarP(&syncForce, "force", "f", false, "Force push even if local and remote have diverged (use with caution)")
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")
}

Expand Down Expand Up @@ -412,17 +412,11 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error {
return fmt.Errorf("failed to fast-forward: %w", err)
}
} else {
// Branches have diverged - this is NOT safe to auto-resolve
fmt.Fprintf(os.Stderr, " Error: local and remote have diverged for %s\n", branch.Name)
fmt.Fprintf(os.Stderr, "\n This usually means:\n")
fmt.Fprintf(os.Stderr, " - Someone else force-pushed to the remote, OR\n")
fmt.Fprintf(os.Stderr, " - You have local commits that differ from remote\n")
fmt.Fprintf(os.Stderr, "\n To resolve:\n")
fmt.Fprintf(os.Stderr, " 1. Check what's on remote: git log origin/%s\n", branch.Name)
fmt.Fprintf(os.Stderr, " 2. Check what's local: git log %s\n", branch.Name)
fmt.Fprintf(os.Stderr, " 3. If remote is correct: git reset --hard origin/%s, then run 'stack sync'\n", branch.Name)
fmt.Fprintf(os.Stderr, " 4. If local is correct: stack sync --force\n")
return errAlreadyPrinted
// Branches have diverged - this is normal after rebasing onto an updated parent
// --force-with-lease will safely handle this during push
if git.Verbose {
fmt.Printf(" Local and remote have diverged (normal after rebase)\n")
}
}
} else if git.Verbose {
fmt.Printf(" Local branch is up-to-date with origin/%s\n", branch.Name)
Expand Down Expand Up @@ -485,28 +479,37 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error {
return gitClient.ForcePush(branch.Name)
}

// Fetch one more time right before push to ensure --force-with-lease has fresh tracking info
// This prevents "stale info" errors if the remote was updated during our rebase
// Fetch one more time right before push to get the current remote SHA
if git.Verbose {
fmt.Printf(" Refreshing remote tracking ref before push...\n")
}
if err := gitClient.FetchBranch(branch.Name); err != nil {
// Non-fatal, continue with push
// Non-fatal, continue with push using plain --force-with-lease
if git.Verbose {
fmt.Fprintf(os.Stderr, " Note: could not refresh tracking ref: %v\n", err)
}
return gitClient.Push(branch.Name, true)
}

// Get the remote SHA to use with explicit --force-with-lease
// This avoids "stale info" errors that can occur with plain --force-with-lease
remoteSha, err := gitClient.GetCommitHash("origin/" + branch.Name)
if err != nil {
// Fall back to plain --force-with-lease
if git.Verbose {
fmt.Fprintf(os.Stderr, " Note: could not get remote SHA, using plain force-with-lease: %v\n", err)
}
return gitClient.Push(branch.Name, true)
}

// Use --force-with-lease (safe force push)
return gitClient.Push(branch.Name, true)
return gitClient.PushWithExpectedRemote(branch.Name, remoteSha)
},
)

if pushErr != nil {
if !syncForce {
fmt.Fprintf(os.Stderr, "\nPossible causes:\n")
fmt.Fprintf(os.Stderr, " 1. Remote branch was updated by someone else - try running 'stack sync' again\n")
fmt.Fprintf(os.Stderr, " 2. Your local branch has diverged from remote - use 'stack sync --force'\n")
fmt.Fprintf(os.Stderr, "\nPossible cause:\n")
fmt.Fprintf(os.Stderr, " Remote branch was updated after fetch - try running 'stack sync' again\n")
}
return fmt.Errorf("push failed for %s", branch.Name)
}
Expand Down
16 changes: 8 additions & 8 deletions cmd/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,14 @@ func TestRunSyncBasic(t *testing.T) {
mockGit.On("GetCommitHash", "origin/feature-a").Return("abc123", nil)
mockGit.On("Rebase", "origin/main").Return(nil)
mockGit.On("FetchBranch", "feature-a").Return(nil)
mockGit.On("Push", "feature-a", true).Return(nil)
mockGit.On("PushWithExpectedRemote", "feature-a", "abc123").Return(nil)
// Process feature-b
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("Rebase", "feature-a").Return(nil)
mockGit.On("FetchBranch", "feature-b").Return(nil)
mockGit.On("Push", "feature-b", true).Return(nil)
mockGit.On("PushWithExpectedRemote", "feature-b", "def456").Return(nil)
// Return to original branch
mockGit.On("CheckoutBranch", "feature-b").Return(nil)

Expand Down Expand Up @@ -126,7 +126,7 @@ func TestRunSyncMergedParent(t *testing.T) {
mockGit.On("GetCommitHash", "origin/feature-b").Return("def456", nil)
mockGit.On("RebaseOnto", "origin/main", "feature-a", "feature-b").Return(nil)
mockGit.On("FetchBranch", "feature-b").Return(nil)
mockGit.On("Push", "feature-b", true).Return(nil)
mockGit.On("PushWithExpectedRemote", "feature-b", "def456").Return(nil)

// Return to original branch
mockGit.On("CheckoutBranch", "feature-b").Return(nil)
Expand Down Expand Up @@ -187,15 +187,15 @@ func TestRunSyncUpdatePRBase(t *testing.T) {
mockGit.On("GetCommitHash", "origin/feature-a").Return("abc123", nil)
mockGit.On("Rebase", "origin/main").Return(nil)
mockGit.On("FetchBranch", "feature-a").Return(nil)
mockGit.On("Push", "feature-a", true).Return(nil)
mockGit.On("PushWithExpectedRemote", "feature-a", "abc123").Return(nil)

// Process feature-b
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("Rebase", "feature-a").Return(nil)
mockGit.On("FetchBranch", "feature-b").Return(nil)
mockGit.On("Push", "feature-b", true).Return(nil)
mockGit.On("PushWithExpectedRemote", "feature-b", "def456").Return(nil)
// Update PR base!
mockGH.On("UpdatePRBase", 2, "feature-a").Return(nil)

Expand Down Expand Up @@ -256,7 +256,7 @@ func TestRunSyncStashHandling(t *testing.T) {
mockGit.On("GetCommitHash", "origin/feature-a").Return("abc123", nil)
mockGit.On("Rebase", "origin/main").Return(nil)
mockGit.On("FetchBranch", "feature-a").Return(nil)
mockGit.On("Push", "feature-a", true).Return(nil)
mockGit.On("PushWithExpectedRemote", "feature-a", "abc123").Return(nil)

mockGit.On("CheckoutBranch", "feature-a").Return(nil)

Expand Down Expand Up @@ -502,7 +502,7 @@ func TestRunSyncResume(t *testing.T) {
mockGit.On("GetCommitHash", "origin/feature-a").Return("abc123", nil)
mockGit.On("Rebase", "origin/main").Return(nil)
mockGit.On("FetchBranch", "feature-a").Return(nil)
mockGit.On("Push", "feature-a", true).Return(nil)
mockGit.On("PushWithExpectedRemote", "feature-a", "abc123").Return(nil)

// Return to original branch (called twice: once for return, once for displayStatusAfterSync)
mockGit.On("CheckoutBranch", "feature-a").Return(nil)
Expand Down Expand Up @@ -559,7 +559,7 @@ func TestRunSyncResume(t *testing.T) {
mockGit.On("GetCommitHash", "origin/feature-a").Return("abc123", nil)
mockGit.On("Rebase", "origin/main").Return(nil)
mockGit.On("FetchBranch", "feature-a").Return(nil)
mockGit.On("Push", "feature-a", true).Return(nil)
mockGit.On("PushWithExpectedRemote", "feature-a", "abc123").Return(nil)

// Return to original branch
mockGit.On("CheckoutBranch", "feature-a").Return(nil)
Expand Down
6 changes: 5 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ stack status --no-pr
```

Flags:

- `--no-pr` - Skip fetching PR information (faster)

## `stack sync`
Expand All @@ -51,7 +52,8 @@ stack sync --force
```

Flags:
- `--force`, `-f` - Force push even if local and remote have diverged (use with caution)

- `--force`, `-f` - Use `--force` instead of `--force-with-lease` for push (bypasses safety checks)

## `stack parent`

Expand Down Expand Up @@ -80,6 +82,7 @@ stack prune --dry-run
```

Flags:

- `--all`, `-a` - Check all local branches, not just stack branches
- `--force`, `-f` - Force delete branches even if they have unmerged commits

Expand Down Expand Up @@ -132,6 +135,7 @@ stack worktree --prune
```

Flags:

- `--prune` - Remove worktrees for branches with merged PRs

## `stack version`
Expand Down
15 changes: 15 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,21 @@ func (c *gitClient) Push(branch string, forceWithLease bool) error {
return err
}

// PushWithExpectedRemote pushes a branch using --force-with-lease with an explicit expected SHA.
// This avoids "stale info" errors that can occur with plain --force-with-lease.
func (c *gitClient) PushWithExpectedRemote(branch string, expectedRemoteSha string) error {
leaseArg := fmt.Sprintf("--force-with-lease=refs/heads/%s:%s", branch, expectedRemoteSha)
args := []string{"push", leaseArg, "origin", branch}

if DryRun {
fmt.Printf(" [DRY RUN] git %s\n", strings.Join(args, " "))
return nil
}

_, err := c.runCmd(args...)
return err
}

// ForcePush force pushes a branch to origin (bypasses --force-with-lease safety)
func (c *gitClient) ForcePush(branch string) error {
args := []string{"push", "--force", "origin", branch}
Expand Down
2 changes: 1 addition & 1 deletion internal/git/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type GitClient interface {
RebaseOnto(newBase, oldBase, currentBranch string) error
FetchBranch(branch string) error
Push(branch string, forceWithLease bool) error
PushWithExpectedRemote(branch string, expectedRemoteSha string) error
ForcePush(branch string) error
IsWorkingTreeClean() (bool, error)
Fetch() error
Expand All @@ -40,4 +41,3 @@ type GitClient interface {
RemoveWorktree(path string) error
ListWorktrees() ([]string, error)
}

6 changes: 5 additions & 1 deletion internal/testutil/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ func (m *MockGitClient) Push(branch string, forceWithLease bool) error {
return args.Error(0)
}

func (m *MockGitClient) PushWithExpectedRemote(branch string, expectedRemoteSha string) error {
args := m.Called(branch, expectedRemoteSha)
return args.Error(0)
}

func (m *MockGitClient) ForcePush(branch string) error {
args := m.Called(branch)
return args.Error(0)
Expand Down Expand Up @@ -222,4 +227,3 @@ func (m *MockGitHubClient) IsPRMerged(prNumber int) (bool, error) {
args := m.Called(prNumber)
return args.Bool(0), args.Error(1)
}