diff --git a/cmd/bd/daemon_sync_branch.go b/cmd/bd/daemon_sync_branch.go index 21748611..3b5a2ca7 100644 --- a/cmd/bd/daemon_sync_branch.go +++ b/cmd/bd/daemon_sync_branch.go @@ -182,7 +182,8 @@ func gitCommitInWorktree(ctx context.Context, worktreePath, filePath, message st return nil } -// gitPushFromWorktree pushes the sync branch from the worktree +// gitPushFromWorktree pushes the sync branch from the worktree. +// If push fails due to remote having newer commits, it will fetch, rebase, and retry. func gitPushFromWorktree(ctx context.Context, worktreePath, branch string) error { // Get remote name (usually "origin") remoteCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "config", "--get", fmt.Sprintf("branch.%s.remote", branch)) // #nosec G204 - worktreePath and branch are from config @@ -197,6 +198,32 @@ func gitPushFromWorktree(ctx context.Context, worktreePath, branch string) error cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "push", "--set-upstream", remote, branch) // #nosec G204 - worktreePath, remote, and branch are from config output, err := cmd.CombinedOutput() if err != nil { + // Check if push failed due to remote having newer commits + outputStr := string(output) + if strings.Contains(outputStr, "fetch first") || strings.Contains(outputStr, "non-fast-forward") { + // Fetch and rebase, then retry push + fetchCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "fetch", remote, branch) // #nosec G204 + if fetchOutput, fetchErr := fetchCmd.CombinedOutput(); fetchErr != nil { + return fmt.Errorf("git fetch failed in worktree: %w\n%s", fetchErr, fetchOutput) + } + + // Rebase local commits on top of remote + rebaseCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rebase", remote+"/"+branch) // #nosec G204 + if rebaseOutput, rebaseErr := rebaseCmd.CombinedOutput(); rebaseErr != nil { + // If rebase fails (conflict), abort and return error + abortCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rebase", "--abort") // #nosec G204 + _ = abortCmd.Run() + return fmt.Errorf("git rebase failed in worktree (sync branch may have conflicts): %w\n%s", rebaseErr, rebaseOutput) + } + + // Retry push after successful rebase + retryCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "push", "--set-upstream", remote, branch) // #nosec G204 + if retryOutput, retryErr := retryCmd.CombinedOutput(); retryErr != nil { + return fmt.Errorf("git push failed after rebase: %w\n%s", retryErr, retryOutput) + } + + return nil + } return fmt.Errorf("git push failed from worktree: %w\n%s", err, output) } diff --git a/cmd/bd/daemon_sync_branch_test.go b/cmd/bd/daemon_sync_branch_test.go index 448e6b1a..78b5c810 100644 --- a/cmd/bd/daemon_sync_branch_test.go +++ b/cmd/bd/daemon_sync_branch_test.go @@ -1430,3 +1430,118 @@ func initMainBranch(t *testing.T, dir string) { runGitCmd(t, dir, "add", "README.md") runGitCmd(t, dir, "commit", "-m", "Initial commit") } + +// TestGitPushFromWorktree_FetchRebaseRetry tests that gitPushFromWorktree handles +// the case where the remote has newer commits by fetching, rebasing, and retrying. +// This is a regression test for the bug where daemon push would fail with +// "fetch first" error when another clone had pushed to the sync branch. +// +// Bug scenario: +// 1. Clone A pushes commit X to sync branch +// 2. Clone B has local commit Y (not based on X) +// 3. Clone B's push fails with "fetch first" error +// 4. Without this fix: daemon logs failure and stops +// 5. With this fix: daemon fetches, rebases Y on X, and retries push +func TestGitPushFromWorktree_FetchRebaseRetry(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Skip on Windows due to path issues + if runtime.GOOS == "windows" { + t.Skip("Skipping on Windows") + } + + ctx := context.Background() + + // Create a "remote" bare repository + remoteDir := t.TempDir() + runGitCmd(t, remoteDir, "init", "--bare") + + // Create first clone (simulates another developer's clone) + clone1Dir := t.TempDir() + runGitCmd(t, clone1Dir, "clone", remoteDir, ".") + runGitCmd(t, clone1Dir, "config", "user.email", "test@example.com") + runGitCmd(t, clone1Dir, "config", "user.name", "Test User") + + // Create initial commit on main + initMainBranch(t, clone1Dir) + runGitCmd(t, clone1Dir, "push", "-u", "origin", "main") + + // Create sync branch in clone1 + runGitCmd(t, clone1Dir, "checkout", "-b", "beads-sync") + beadsDir1 := filepath.Join(clone1Dir, ".beads") + if err := os.MkdirAll(beadsDir1, 0755); err != nil { + t.Fatalf("Failed to create .beads dir: %v", err) + } + jsonl1 := filepath.Join(beadsDir1, "issues.jsonl") + if err := os.WriteFile(jsonl1, []byte(`{"id":"clone1-issue","title":"Issue from clone1"}`+"\n"), 0644); err != nil { + t.Fatalf("Failed to write JSONL: %v", err) + } + runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl") + runGitCmd(t, clone1Dir, "commit", "-m", "Clone 1 commit") + runGitCmd(t, clone1Dir, "push", "-u", "origin", "beads-sync") + + // Create second clone (simulates our local clone) + clone2Dir := t.TempDir() + runGitCmd(t, clone2Dir, "clone", remoteDir, ".") + runGitCmd(t, clone2Dir, "config", "user.email", "test@example.com") + runGitCmd(t, clone2Dir, "config", "user.name", "Test User") + + // Create worktree for sync branch in clone2 + worktreePath := filepath.Join(clone2Dir, ".git", "beads-worktrees", "beads-sync") + if err := os.MkdirAll(filepath.Dir(worktreePath), 0755); err != nil { + t.Fatalf("Failed to create worktree parent: %v", err) + } + + // Fetch the sync branch first + runGitCmd(t, clone2Dir, "fetch", "origin", "beads-sync:beads-sync") + + // Create worktree - but don't pull latest yet (to simulate diverged state) + runGitCmd(t, clone2Dir, "worktree", "add", worktreePath, "beads-sync") + + // Now clone1 makes another commit and pushes (simulating another clone pushing) + runGitCmd(t, clone1Dir, "checkout", "beads-sync") + if err := os.WriteFile(jsonl1, []byte(`{"id":"clone1-issue","title":"Issue from clone1"}`+"\n"+`{"id":"clone1-issue2","title":"Second issue"}`+"\n"), 0644); err != nil { + t.Fatalf("Failed to update JSONL: %v", err) + } + runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl") + runGitCmd(t, clone1Dir, "commit", "-m", "Clone 1 second commit") + runGitCmd(t, clone1Dir, "push", "origin", "beads-sync") + + // Clone2's worktree makes a different commit (diverged from remote) + // We create a different file to avoid merge conflicts - this simulates + // non-conflicting JSONL changes (e.g., different issues being created) + beadsDir2 := filepath.Join(worktreePath, ".beads") + if err := os.MkdirAll(beadsDir2, 0755); err != nil { + t.Fatalf("Failed to create .beads in worktree: %v", err) + } + // Create a separate metadata file to avoid JSONL conflict + metadataPath := filepath.Join(beadsDir2, "metadata.json") + if err := os.WriteFile(metadataPath, []byte(`{"clone":"clone2"}`+"\n"), 0644); err != nil { + t.Fatalf("Failed to write metadata in worktree: %v", err) + } + runGitCmd(t, worktreePath, "add", ".beads/metadata.json") + runGitCmd(t, worktreePath, "commit", "-m", "Clone 2 commit") + + // Now try to push from worktree - this should trigger the fetch-rebase-retry logic + // because the remote has commits that the local worktree doesn't have + err := gitPushFromWorktree(ctx, worktreePath, "beads-sync") + if err != nil { + t.Fatalf("gitPushFromWorktree failed: %v (expected fetch-rebase-retry to succeed)", err) + } + + // Verify the push succeeded by checking the remote has all commits + cmd := exec.Command("git", "-C", remoteDir, "rev-list", "--count", "beads-sync") + output, err := cmd.Output() + if err != nil { + t.Fatalf("Failed to count commits: %v", err) + } + commitCount := strings.TrimSpace(string(output)) + // Should have at least 3 commits: initial sync, clone1's second commit, clone2's rebased commit + if commitCount == "0" || commitCount == "1" || commitCount == "2" { + t.Errorf("Expected at least 3 commits after rebase-push, got %s", commitCount) + } + + t.Log("Fetch-rebase-retry test passed: diverged sync branch was successfully rebased and pushed") +}