Implement auto-resolution of JSONL merge conflicts during bd sync

- Add isInRebase() to detect rebase state
- Add hasJSONLConflict() to check for JSONL-only conflicts
- Add runGitRebaseContinue() to continue rebase after resolution
- Auto-export from DB and resolve conflict when detected
- Add comprehensive tests for auto-resolution logic

Implements bd-cwmt
This commit is contained in:
Steve Yegge
2025-11-23 20:41:35 -08:00
parent 3ca11dc440
commit 3cf5e26d1e
3 changed files with 265 additions and 12 deletions

View File

@@ -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))

View File

@@ -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() {

View File

@@ -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)")
}
}