diff --git a/cmd/bd/init.go b/cmd/bd/init.go index 17fc65cd..ad2861fa 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -353,7 +353,6 @@ With --no-db: creates .beads/ directory and issues.jsonl file instead of SQLite green := color.New(color.FgGreen).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() fmt.Printf("\n%s bd initialized successfully!\n\n", green("✓")) fmt.Printf(" Database: %s\n", cyan(initDBPath)) diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index 0d5f8869..34eeeb46 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -204,19 +204,49 @@ Use --merge to merge the sync branch back to main branch.`, fmt.Println("→ Pulling from remote...") if err := gitPull(ctx); err != nil { - fmt.Fprintf(os.Stderr, "Error pulling: %v\n", err) + // Check if it's a rebase conflict on beads.jsonl that we can auto-resolve + if isInRebase() && hasJSONLConflict() { + fmt.Println("→ Auto-resolving JSONL merge conflict...") - // Check if this looks like a merge driver failure - errStr := err.Error() - if strings.Contains(errStr, "merge driver") || - strings.Contains(errStr, "no such file or directory") || - strings.Contains(errStr, "MERGE DRIVER INVOKED") { - fmt.Fprintf(os.Stderr, "\nThis may be caused by an incorrect merge driver configuration.\n") - fmt.Fprintf(os.Stderr, "Fix: bd doctor --fix\n\n") + // Export clean JSONL from DB (database is source of truth) + if exportErr := exportToJSONL(ctx, jsonlPath); exportErr != nil { + fmt.Fprintf(os.Stderr, "Error: failed to export for conflict resolution: %v\n", exportErr) + fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n") + os.Exit(1) + } + + // Mark conflict as resolved + addCmd := exec.CommandContext(ctx, "git", "add", jsonlPath) + if addErr := addCmd.Run(); addErr != nil { + fmt.Fprintf(os.Stderr, "Error: failed to mark conflict resolved: %v\n", addErr) + fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n") + os.Exit(1) + } + + // Continue rebase + if continueErr := runGitRebaseContinue(ctx); continueErr != nil { + fmt.Fprintf(os.Stderr, "Error: failed to continue rebase: %v\n", continueErr) + fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n") + os.Exit(1) + } + + fmt.Println("✓ Auto-resolved JSONL conflict") + } else { + // Not an auto-resolvable conflict, fail with original error + fmt.Fprintf(os.Stderr, "Error pulling: %v\n", err) + + // Check if this looks like a merge driver failure + errStr := err.Error() + if strings.Contains(errStr, "merge driver") || + strings.Contains(errStr, "no such file or directory") || + strings.Contains(errStr, "MERGE DRIVER INVOKED") { + fmt.Fprintf(os.Stderr, "\nThis may be caused by an incorrect merge driver configuration.\n") + fmt.Fprintf(os.Stderr, "Fix: bd doctor --fix\n\n") + } + + fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n") + os.Exit(1) } - - fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n") - os.Exit(1) } // Count issues before import for validation @@ -439,6 +469,64 @@ func hasGitRemote(ctx context.Context) bool { return len(strings.TrimSpace(string(output))) > 0 } +// isInRebase checks if we're currently in a git rebase state +func isInRebase() bool { + // Check for rebase-merge directory (interactive rebase) + if _, err := os.Stat(".git/rebase-merge"); err == nil { + return true + } + // Check for rebase-apply directory (non-interactive rebase) + if _, err := os.Stat(".git/rebase-apply"); err == nil { + return true + } + return false +} + +// hasJSONLConflict checks if beads.jsonl has a merge conflict +// Returns true only if beads.jsonl is the only file in conflict +func hasJSONLConflict() bool { + cmd := exec.Command("git", "status", "--porcelain") + out, err := cmd.Output() + if err != nil { + return false + } + + var hasJSONLConflict bool + var hasOtherConflict bool + + for _, line := range strings.Split(string(out), "\n") { + if len(line) < 3 { + continue + } + + // Check for unmerged status codes (UU = both modified, AA = both added, etc.) + status := line[:2] + if status == "UU" || status == "AA" || status == "DD" || + status == "AU" || status == "UA" || status == "DU" || status == "UD" { + filepath := strings.TrimSpace(line[3:]) + + if strings.HasSuffix(filepath, "beads.jsonl") { + hasJSONLConflict = true + } else { + hasOtherConflict = true + } + } + } + + // Only return true if ONLY beads.jsonl has a conflict + return hasJSONLConflict && !hasOtherConflict +} + +// runGitRebaseContinue continues a rebase after resolving conflicts +func runGitRebaseContinue(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "git", "rebase", "--continue") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git rebase --continue failed: %w\n%s", err, output) + } + return nil +} + // gitPull pulls from the current branch's upstream // Returns nil if no remote configured (local-only mode) func checkMergeDriverConfig() { diff --git a/cmd/bd/sync_test.go b/cmd/bd/sync_test.go index 36bc1844..bf4ff212 100644 --- a/cmd/bd/sync_test.go +++ b/cmd/bd/sync_test.go @@ -441,3 +441,169 @@ func TestGetSyncBranch_EnvOverridesDB(t *testing.T) { t.Errorf("getSyncBranch() = %q, want %q (env override)", branch, "env-branch") } } + +func TestIsInRebase_NotInRebase(t *testing.T) { + tmpDir := t.TempDir() + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Create a git repo + os.Chdir(tmpDir) + exec.Command("git", "init").Run() + exec.Command("git", "config", "user.email", "test@test.com").Run() + exec.Command("git", "config", "user.name", "Test User").Run() + + // Create initial commit + os.WriteFile("test.txt", []byte("test"), 0644) + exec.Command("git", "add", "test.txt").Run() + exec.Command("git", "commit", "-m", "initial").Run() + + // Should not be in rebase + if isInRebase() { + t.Error("expected false when not in rebase") + } +} + +func TestIsInRebase_InRebase(t *testing.T) { + tmpDir := t.TempDir() + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Create a git repo + os.Chdir(tmpDir) + exec.Command("git", "init").Run() + exec.Command("git", "config", "user.email", "test@test.com").Run() + exec.Command("git", "config", "user.name", "Test User").Run() + + // Create initial commit + os.WriteFile("test.txt", []byte("test"), 0644) + exec.Command("git", "add", "test.txt").Run() + exec.Command("git", "commit", "-m", "initial").Run() + + // Simulate rebase by creating rebase-merge directory + os.MkdirAll(filepath.Join(tmpDir, ".git", "rebase-merge"), 0755) + + // Should detect rebase + if !isInRebase() { + t.Error("expected true when .git/rebase-merge exists") + } +} + +func TestIsInRebase_InRebaseApply(t *testing.T) { + tmpDir := t.TempDir() + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Create a git repo + os.Chdir(tmpDir) + exec.Command("git", "init").Run() + + // Simulate non-interactive rebase by creating rebase-apply directory + os.MkdirAll(filepath.Join(tmpDir, ".git", "rebase-apply"), 0755) + + // Should detect rebase + if !isInRebase() { + t.Error("expected true when .git/rebase-apply exists") + } +} + +func TestHasJSONLConflict_NoConflict(t *testing.T) { + tmpDir := t.TempDir() + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Create a git repo + os.Chdir(tmpDir) + exec.Command("git", "init").Run() + exec.Command("git", "config", "user.email", "test@test.com").Run() + exec.Command("git", "config", "user.name", "Test User").Run() + + // Create initial commit + os.WriteFile("test.txt", []byte("test"), 0644) + exec.Command("git", "add", "test.txt").Run() + exec.Command("git", "commit", "-m", "initial").Run() + + // Should not have JSONL conflict + if hasJSONLConflict() { + t.Error("expected false when no conflicts") + } +} + +func TestHasJSONLConflict_OnlyJSONLConflict(t *testing.T) { + tmpDir := t.TempDir() + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Create a git repo + os.Chdir(tmpDir) + exec.Command("git", "init", "-b", "main").Run() + exec.Command("git", "config", "user.email", "test@test.com").Run() + exec.Command("git", "config", "user.name", "Test User").Run() + + // Create initial commit + beadsDir := filepath.Join(tmpDir, ".beads") + os.MkdirAll(beadsDir, 0755) + os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"original"}`), 0644) + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "initial").Run() + + // Create a second commit on main (modify same issue) + os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"main-version"}`), 0644) + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "main change").Run() + + // Create a branch from the first commit + exec.Command("git", "checkout", "-b", "feature", "HEAD~1").Run() + os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"feature-version"}`), 0644) + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "feature change").Run() + + // Attempt rebase onto main (will conflict) + exec.Command("git", "rebase", "main").Run() + + // Should detect JSONL conflict during rebase + if !hasJSONLConflict() { + t.Error("expected true when only beads.jsonl has conflict during rebase") + } +} + +func TestHasJSONLConflict_MultipleConflicts(t *testing.T) { + tmpDir := t.TempDir() + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Create a git repo + os.Chdir(tmpDir) + exec.Command("git", "init", "-b", "main").Run() + exec.Command("git", "config", "user.email", "test@test.com").Run() + exec.Command("git", "config", "user.name", "Test User").Run() + + // Create initial commit with beads.jsonl and another file + beadsDir := filepath.Join(tmpDir, ".beads") + os.MkdirAll(beadsDir, 0755) + os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"original"}`), 0644) + os.WriteFile("other.txt", []byte("line1\nline2\nline3"), 0644) + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "initial").Run() + + // Create a second commit on main (modify both files) + os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"main-version"}`), 0644) + os.WriteFile("other.txt", []byte("line1\nmain-version\nline3"), 0644) + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "main change").Run() + + // Create a branch from the first commit + exec.Command("git", "checkout", "-b", "feature", "HEAD~1").Run() + os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"feature-version"}`), 0644) + os.WriteFile("other.txt", []byte("line1\nfeature-version\nline3"), 0644) + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "feature change").Run() + + // Attempt rebase (will conflict on both files) + exec.Command("git", "rebase", "main").Run() + + // Should NOT auto-resolve when multiple files conflict + if hasJSONLConflict() { + t.Error("expected false when multiple files have conflicts (should not auto-resolve)") + } +}