Files
beads/internal/syncbranch/worktree_divergence_test.go
Steve Yegge 875c55c2dc 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>
2025-12-02 18:25:56 -08:00

461 lines
16 KiB
Go

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