fix(sync): handle diverged histories with content-based merge (bd-3s8)
When multiple clones commit to beads-sync branch and histories diverge, git merge would fail. This replaces git's commit-level merge with a content-based merge that extracts JSONL from base/local/remote and merges at the semantic level. Key changes: - Add divergence detection using git rev-list --left-right - Extract JSONL content from specific commits for 3-way merge - Reset to remote's history then commit merged content on top - Pre-emptive fetch before commit to reduce divergence likelihood - Deletions.jsonl merged by union (keeps all deletions) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
460
internal/syncbranch/worktree_divergence_test.go
Normal file
460
internal/syncbranch/worktree_divergence_test.go
Normal file
@@ -0,0 +1,460 @@
|
||||
package syncbranch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetDivergence tests the divergence detection function
|
||||
func TestGetDivergence(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("no divergence when synced", func(t *testing.T) {
|
||||
// Create a test repo with a branch
|
||||
repoDir := setupTestRepo(t)
|
||||
defer os.RemoveAll(repoDir)
|
||||
|
||||
// Create and checkout test branch
|
||||
runGit(t, repoDir, "checkout", "-b", "test-branch")
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "initial commit")
|
||||
|
||||
// Simulate remote by creating a local ref
|
||||
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
|
||||
|
||||
localAhead, remoteAhead, err := getDivergence(ctx, repoDir, "test-branch", "origin")
|
||||
if err != nil {
|
||||
t.Fatalf("getDivergence() error = %v", err)
|
||||
}
|
||||
if localAhead != 0 || remoteAhead != 0 {
|
||||
t.Errorf("getDivergence() = (%d, %d), want (0, 0)", localAhead, remoteAhead)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local ahead of remote", func(t *testing.T) {
|
||||
repoDir := setupTestRepo(t)
|
||||
defer os.RemoveAll(repoDir)
|
||||
|
||||
runGit(t, repoDir, "checkout", "-b", "test-branch")
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "initial commit")
|
||||
|
||||
// Set remote ref to current HEAD
|
||||
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
|
||||
|
||||
// Add more local commits
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}
|
||||
{"id":"test-2"}`)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "second commit")
|
||||
|
||||
localAhead, remoteAhead, err := getDivergence(ctx, repoDir, "test-branch", "origin")
|
||||
if err != nil {
|
||||
t.Fatalf("getDivergence() error = %v", err)
|
||||
}
|
||||
if localAhead != 1 || remoteAhead != 0 {
|
||||
t.Errorf("getDivergence() = (%d, %d), want (1, 0)", localAhead, remoteAhead)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote ahead of local", func(t *testing.T) {
|
||||
repoDir := setupTestRepo(t)
|
||||
defer os.RemoveAll(repoDir)
|
||||
|
||||
runGit(t, repoDir, "checkout", "-b", "test-branch")
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "initial commit")
|
||||
|
||||
// Save current HEAD as "local"
|
||||
localHead := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
|
||||
|
||||
// Create more commits and set as remote
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}
|
||||
{"id":"test-2"}`)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "remote commit")
|
||||
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
|
||||
|
||||
// Reset local to previous commit
|
||||
runGit(t, repoDir, "reset", "--hard", localHead)
|
||||
|
||||
localAhead, remoteAhead, err := getDivergence(ctx, repoDir, "test-branch", "origin")
|
||||
if err != nil {
|
||||
t.Fatalf("getDivergence() error = %v", err)
|
||||
}
|
||||
if localAhead != 0 || remoteAhead != 1 {
|
||||
t.Errorf("getDivergence() = (%d, %d), want (0, 1)", localAhead, remoteAhead)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("diverged histories", func(t *testing.T) {
|
||||
repoDir := setupTestRepo(t)
|
||||
defer os.RemoveAll(repoDir)
|
||||
|
||||
runGit(t, repoDir, "checkout", "-b", "test-branch")
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "base commit")
|
||||
|
||||
// Save base commit
|
||||
baseCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
|
||||
|
||||
// Create local commit
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}
|
||||
{"id":"local-2"}`)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "local commit")
|
||||
localHead := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
|
||||
|
||||
// Create remote commit from base
|
||||
runGit(t, repoDir, "checkout", baseCommit)
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}
|
||||
{"id":"remote-2"}`)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "remote commit")
|
||||
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
|
||||
|
||||
// Go back to local branch
|
||||
runGit(t, repoDir, "checkout", "-B", "test-branch", localHead)
|
||||
|
||||
localAhead, remoteAhead, err := getDivergence(ctx, repoDir, "test-branch", "origin")
|
||||
if err != nil {
|
||||
t.Fatalf("getDivergence() error = %v", err)
|
||||
}
|
||||
if localAhead != 1 || remoteAhead != 1 {
|
||||
t.Errorf("getDivergence() = (%d, %d), want (1, 1)", localAhead, remoteAhead)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestExtractJSONLFromCommit tests extracting JSONL content from git commits
|
||||
func TestExtractJSONLFromCommit(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("extracts file from HEAD", func(t *testing.T) {
|
||||
repoDir := setupTestRepo(t)
|
||||
defer os.RemoveAll(repoDir)
|
||||
|
||||
content := `{"id":"test-1","title":"Test Issue"}`
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), content)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "test commit")
|
||||
|
||||
extracted, err := extractJSONLFromCommit(ctx, repoDir, "HEAD", ".beads/issues.jsonl")
|
||||
if err != nil {
|
||||
t.Fatalf("extractJSONLFromCommit() error = %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(extracted)) != content {
|
||||
t.Errorf("extractJSONLFromCommit() = %q, want %q", extracted, content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extracts file from specific commit", func(t *testing.T) {
|
||||
repoDir := setupTestRepo(t)
|
||||
defer os.RemoveAll(repoDir)
|
||||
|
||||
// First commit
|
||||
content1 := `{"id":"test-1"}`
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), content1)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "first commit")
|
||||
firstCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
|
||||
|
||||
// Second commit
|
||||
content2 := `{"id":"test-1"}
|
||||
{"id":"test-2"}`
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), content2)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "second commit")
|
||||
|
||||
// Extract from first commit
|
||||
extracted, err := extractJSONLFromCommit(ctx, repoDir, firstCommit, ".beads/issues.jsonl")
|
||||
if err != nil {
|
||||
t.Fatalf("extractJSONLFromCommit() error = %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(extracted)) != content1 {
|
||||
t.Errorf("extractJSONLFromCommit() = %q, want %q", extracted, content1)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error for nonexistent file", func(t *testing.T) {
|
||||
repoDir := setupTestRepo(t)
|
||||
defer os.RemoveAll(repoDir)
|
||||
|
||||
writeFile(t, filepath.Join(repoDir, "dummy.txt"), "dummy")
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "test commit")
|
||||
|
||||
_, err := extractJSONLFromCommit(ctx, repoDir, "HEAD", ".beads/issues.jsonl")
|
||||
if err == nil {
|
||||
t.Error("extractJSONLFromCommit() expected error for nonexistent file")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPerformContentMerge tests the content-based merge function
|
||||
func TestPerformContentMerge(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("merges diverged content", func(t *testing.T) {
|
||||
repoDir := setupTestRepo(t)
|
||||
defer os.RemoveAll(repoDir)
|
||||
|
||||
runGit(t, repoDir, "checkout", "-b", "test-branch")
|
||||
|
||||
// Base content
|
||||
baseContent := `{"id":"test-1","title":"Base","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), baseContent)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "base commit")
|
||||
baseCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
|
||||
|
||||
// Create local changes (add issue)
|
||||
localContent := `{"id":"test-1","title":"Base","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
|
||||
{"id":"local-1","title":"Local Issue","created_at":"2024-01-02T00:00:00Z","created_by":"user1"}`
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), localContent)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "local commit")
|
||||
localHead := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
|
||||
|
||||
// Create remote changes from base (add different issue)
|
||||
runGit(t, repoDir, "checkout", baseCommit)
|
||||
remoteContent := `{"id":"test-1","title":"Base","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
|
||||
{"id":"remote-1","title":"Remote Issue","created_at":"2024-01-02T00:00:00Z","created_by":"user2"}`
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), remoteContent)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "remote commit")
|
||||
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
|
||||
|
||||
// Go back to local
|
||||
runGit(t, repoDir, "checkout", "-B", "test-branch", localHead)
|
||||
|
||||
// Perform merge
|
||||
merged, err := performContentMerge(ctx, repoDir, "test-branch", "origin", ".beads/issues.jsonl")
|
||||
if err != nil {
|
||||
t.Fatalf("performContentMerge() error = %v", err)
|
||||
}
|
||||
|
||||
// Check that merged content contains all three issues
|
||||
mergedStr := string(merged)
|
||||
if !strings.Contains(mergedStr, "test-1") {
|
||||
t.Error("merged content missing base issue test-1")
|
||||
}
|
||||
if !strings.Contains(mergedStr, "local-1") {
|
||||
t.Error("merged content missing local issue local-1")
|
||||
}
|
||||
if !strings.Contains(mergedStr, "remote-1") {
|
||||
t.Error("merged content missing remote issue remote-1")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles deletion correctly", func(t *testing.T) {
|
||||
repoDir := setupTestRepo(t)
|
||||
defer os.RemoveAll(repoDir)
|
||||
|
||||
runGit(t, repoDir, "checkout", "-b", "test-branch")
|
||||
|
||||
// Base content with two issues
|
||||
baseContent := `{"id":"test-1","title":"Issue 1","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
|
||||
{"id":"test-2","title":"Issue 2","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), baseContent)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "base commit")
|
||||
baseCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
|
||||
|
||||
// Local keeps both
|
||||
localHead := baseCommit
|
||||
|
||||
// Remote deletes test-2
|
||||
runGit(t, repoDir, "checkout", baseCommit)
|
||||
remoteContent := `{"id":"test-1","title":"Issue 1","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), remoteContent)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "remote delete")
|
||||
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
|
||||
|
||||
// Go back to local
|
||||
runGit(t, repoDir, "checkout", "-B", "test-branch", localHead)
|
||||
|
||||
// Perform merge
|
||||
merged, err := performContentMerge(ctx, repoDir, "test-branch", "origin", ".beads/issues.jsonl")
|
||||
if err != nil {
|
||||
t.Fatalf("performContentMerge() error = %v", err)
|
||||
}
|
||||
|
||||
// Deletion should win - test-2 should be gone
|
||||
mergedStr := string(merged)
|
||||
if !strings.Contains(mergedStr, "test-1") {
|
||||
t.Error("merged content missing issue test-1")
|
||||
}
|
||||
if strings.Contains(mergedStr, "test-2") {
|
||||
t.Error("merged content still contains deleted issue test-2")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPerformDeletionsMerge tests the deletions merge function
|
||||
func TestPerformDeletionsMerge(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("merges deletions from both sides", func(t *testing.T) {
|
||||
repoDir := setupTestRepo(t)
|
||||
defer os.RemoveAll(repoDir)
|
||||
|
||||
runGit(t, repoDir, "checkout", "-b", "test-branch")
|
||||
|
||||
// Base: no deletions
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "base commit")
|
||||
baseCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
|
||||
|
||||
// Local: delete issue-A
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "deletions.jsonl"), `{"id":"issue-A","deleted_at":"2024-01-01T00:00:00Z"}`)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "local deletion")
|
||||
localHead := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
|
||||
|
||||
// Remote: delete issue-B
|
||||
runGit(t, repoDir, "checkout", baseCommit)
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "deletions.jsonl"), `{"id":"issue-B","deleted_at":"2024-01-02T00:00:00Z"}`)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "remote deletion")
|
||||
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
|
||||
|
||||
// Go back to local
|
||||
runGit(t, repoDir, "checkout", "-B", "test-branch", localHead)
|
||||
|
||||
// Perform merge
|
||||
merged, err := performDeletionsMerge(ctx, repoDir, "test-branch", "origin", ".beads/deletions.jsonl")
|
||||
if err != nil {
|
||||
t.Fatalf("performDeletionsMerge() error = %v", err)
|
||||
}
|
||||
|
||||
// Both deletions should be present
|
||||
mergedStr := string(merged)
|
||||
if !strings.Contains(mergedStr, "issue-A") {
|
||||
t.Error("merged deletions missing issue-A")
|
||||
}
|
||||
if !strings.Contains(mergedStr, "issue-B") {
|
||||
t.Error("merged deletions missing issue-B")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles only local deletions", func(t *testing.T) {
|
||||
repoDir := setupTestRepo(t)
|
||||
defer os.RemoveAll(repoDir)
|
||||
|
||||
runGit(t, repoDir, "checkout", "-b", "test-branch")
|
||||
|
||||
// Base: no deletions
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "base commit")
|
||||
baseCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
|
||||
|
||||
// Local: has deletions
|
||||
writeFile(t, filepath.Join(repoDir, ".beads", "deletions.jsonl"), `{"id":"issue-A"}`)
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "local deletion")
|
||||
localHead := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
|
||||
|
||||
// Remote: no deletions file
|
||||
runGit(t, repoDir, "checkout", baseCommit)
|
||||
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
|
||||
|
||||
// Go back to local
|
||||
runGit(t, repoDir, "checkout", "-B", "test-branch", localHead)
|
||||
|
||||
// Perform merge
|
||||
merged, err := performDeletionsMerge(ctx, repoDir, "test-branch", "origin", ".beads/deletions.jsonl")
|
||||
if err != nil {
|
||||
t.Fatalf("performDeletionsMerge() error = %v", err)
|
||||
}
|
||||
|
||||
// Local deletions should be present
|
||||
if !strings.Contains(string(merged), "issue-A") {
|
||||
t.Error("merged deletions missing issue-A")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func setupTestRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "bd-test-repo-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
// Initialize git repo
|
||||
runGit(t, tmpDir, "init")
|
||||
runGit(t, tmpDir, "config", "user.email", "test@test.com")
|
||||
runGit(t, tmpDir, "config", "user.name", "Test User")
|
||||
|
||||
// Create .beads directory
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
t.Fatalf("Failed to create .beads dir: %v", err)
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func getGitOutput(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v", args, err)
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
t.Fatalf("Failed to create directory %s: %v", dir, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to write file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user