Files
beads/internal/syncbranch/worktree_divergence_test.go
Steve Yegge 2a6191e49b fix: use content-level merge for divergence recovery (bd-kpy)
Replace fetchAndRebaseInWorktree with contentMergeRecovery in pushFromWorktree.

The problem: When push fails due to non-fast-forward, the old code used git
rebase to recover. But git rebase is text-level and does not invoke the JSONL
merge driver. This could resurrect tombstones - if remote had a tombstone and
local had closed, the rebase would overwrite the tombstone.

The fix: Use the same content-level merge algorithm that PullFromSyncBranch
uses. This respects tombstone semantics - recent tombstones always win over
live issues.

Changes:
- Add contentMergeRecovery() that does content-level merge instead of rebase
- Update pushFromWorktree to call contentMergeRecovery
- Mark fetchAndRebaseInWorktree as deprecated (kept for reference)
- Add tests for tombstone preservation during merge recovery

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 22:41:40 -08:00

798 lines
26 KiB
Go

package syncbranch
import (
"context"
"os"
"os/exec"
"path/filepath"
"strconv"
"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")
}
})
}
// 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)
}
}
// TestParseIssuesFromContent tests the issue parsing helper function (bd-lsa)
func TestParseIssuesFromContent(t *testing.T) {
tests := []struct {
name string
content []byte
wantIDs []string
}{
{
name: "empty content",
content: []byte{},
wantIDs: []string{},
},
{
name: "nil content",
content: nil,
wantIDs: []string{},
},
{
name: "single issue",
content: []byte(`{"id":"test-1","title":"Test Issue"}`),
wantIDs: []string{"test-1"},
},
{
name: "multiple issues",
content: []byte(`{"id":"test-1","title":"Issue 1"}` + "\n" + `{"id":"test-2","title":"Issue 2"}`),
wantIDs: []string{"test-1", "test-2"},
},
{
name: "malformed line skipped",
content: []byte(`{"id":"test-1","title":"Valid"}` + "\n" + `invalid json` + "\n" + `{"id":"test-2","title":"Also Valid"}`),
wantIDs: []string{"test-1", "test-2"},
},
{
name: "empty id skipped",
content: []byte(`{"id":"","title":"No ID"}` + "\n" + `{"id":"test-1","title":"Has ID"}`),
wantIDs: []string{"test-1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseIssuesFromContent(tt.content)
if len(got) != len(tt.wantIDs) {
t.Errorf("parseIssuesFromContent() returned %d issues, want %d", len(got), len(tt.wantIDs))
return
}
for _, wantID := range tt.wantIDs {
if _, exists := got[wantID]; !exists {
t.Errorf("parseIssuesFromContent() missing expected ID %q", wantID)
}
}
})
}
}
// TestSafetyCheckMassDeletion tests the safety check behavior for mass deletions (bd-cnn)
func TestSafetyCheckMassDeletion(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
t.Run("safety check triggers when >50% issues vanish and >5 existed", func(t *testing.T) {
// Test the countIssuesInContent and formatVanishedIssues functions
// Create local content with 10 issues
// bd-c5m: Use strconv.Itoa instead of string(rune()) which only works for single digits
var localLines []string
for i := 1; i <= 10; i++ {
localLines = append(localLines, `{"id":"test-`+strconv.Itoa(i)+`","title":"Issue `+strconv.Itoa(i)+`"}`)
}
localContent := []byte(strings.Join(localLines, "\n"))
// Create merged content with only 4 issues (60% vanished)
mergedContent := []byte(`{"id":"test-1","title":"Issue 1"}
{"id":"test-2","title":"Issue 2"}
{"id":"test-3","title":"Issue 3"}
{"id":"test-4","title":"Issue 4"}`)
localCount := countIssuesInContent(localContent)
mergedCount := countIssuesInContent(mergedContent)
if localCount != 10 {
t.Errorf("localCount = %d, want 10", localCount)
}
if mergedCount != 4 {
t.Errorf("mergedCount = %d, want 4", mergedCount)
}
// Verify safety check would trigger: >50% vanished AND >5 existed
if localCount <= 5 {
t.Error("localCount should be > 5 for safety check to apply")
}
vanishedPercent := float64(localCount-mergedCount) / float64(localCount) * 100
if vanishedPercent <= 50 {
t.Errorf("vanishedPercent = %.0f%%, want > 50%%", vanishedPercent)
}
// Verify forensic info can be generated
localIssues := parseIssuesFromContent(localContent)
mergedIssues := parseIssuesFromContent(mergedContent)
forensicLines := formatVanishedIssues(localIssues, mergedIssues, localCount, mergedCount)
if len(forensicLines) == 0 {
t.Error("formatVanishedIssues returned empty lines")
}
// bd-8uk: Verify SafetyWarnings would be populated correctly
// Simulate what PullFromSyncBranch does when safety check triggers
var safetyWarnings []string
safetyWarnings = append(safetyWarnings,
"⚠️ Warning: "+strconv.FormatFloat(vanishedPercent, 'f', 0, 64)+"% of issues vanished during merge ("+
strconv.Itoa(localCount)+" → "+strconv.Itoa(mergedCount)+" issues)")
safetyWarnings = append(safetyWarnings, forensicLines...)
// Verify warnings contains expected content
if len(safetyWarnings) < 2 {
t.Errorf("SafetyWarnings should have at least 2 entries (warning + forensics), got %d", len(safetyWarnings))
}
if !strings.Contains(safetyWarnings[0], "Warning") {
t.Error("First SafetyWarning should contain 'Warning'")
}
if !strings.Contains(safetyWarnings[0], "60%") {
t.Errorf("First SafetyWarning should contain '60%%', got: %s", safetyWarnings[0])
}
})
t.Run("safety check does NOT trigger when <50% issues vanish", func(t *testing.T) {
// 10 issues, 6 remain = 40% vanished (should NOT trigger)
// bd-c5m: Use strconv.Itoa instead of string(rune()) which only works for single digits
var localLines []string
for i := 1; i <= 10; i++ {
localLines = append(localLines, `{"id":"test-`+strconv.Itoa(i)+`"}`)
}
localContent := []byte(strings.Join(localLines, "\n"))
// 6 issues remain (40% vanished)
var mergedLines []string
for i := 1; i <= 6; i++ {
mergedLines = append(mergedLines, `{"id":"test-`+strconv.Itoa(i)+`"}`)
}
mergedContent := []byte(strings.Join(mergedLines, "\n"))
localCount := countIssuesInContent(localContent)
mergedCount := countIssuesInContent(mergedContent)
vanishedPercent := float64(localCount-mergedCount) / float64(localCount) * 100
if vanishedPercent > 50 {
t.Errorf("vanishedPercent = %.0f%%, want <= 50%%", vanishedPercent)
}
})
t.Run("safety check does NOT trigger when <5 issues existed", func(t *testing.T) {
// 4 issues, 1 remains = 75% vanished, but only 4 existed (should NOT trigger)
localContent := []byte(`{"id":"test-1"}
{"id":"test-2"}
{"id":"test-3"}
{"id":"test-4"}`)
mergedContent := []byte(`{"id":"test-1"}`)
localCount := countIssuesInContent(localContent)
mergedCount := countIssuesInContent(mergedContent)
if localCount != 4 {
t.Errorf("localCount = %d, want 4", localCount)
}
if mergedCount != 1 {
t.Errorf("mergedCount = %d, want 1", mergedCount)
}
// localCount > 5 is false, so safety check should NOT trigger
if localCount > 5 {
t.Error("localCount should be <= 5 for this test case")
}
})
}
// TestCountIssuesInContent tests the issue counting helper function (bd-7ch)
func TestCountIssuesInContent(t *testing.T) {
tests := []struct {
name string
content []byte
want int
}{
{
name: "empty content",
content: []byte{},
want: 0,
},
{
name: "nil content",
content: nil,
want: 0,
},
{
name: "single issue",
content: []byte(`{"id":"test-1"}`),
want: 1,
},
{
name: "multiple issues",
content: []byte(`{"id":"test-1"}` + "\n" + `{"id":"test-2"}` + "\n" + `{"id":"test-3"}`),
want: 3,
},
{
name: "trailing newline",
content: []byte(`{"id":"test-1"}` + "\n" + `{"id":"test-2"}` + "\n"),
want: 2,
},
{
name: "empty lines ignored",
content: []byte(`{"id":"test-1"}` + "\n" + "\n" + `{"id":"test-2"}` + "\n" + " " + "\n"),
want: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := countIssuesInContent(tt.content)
if got != tt.want {
t.Errorf("countIssuesInContent() = %d, want %d", got, tt.want)
}
})
}
}
// TestIsSyncBranchSameAsCurrent tests detection of sync.branch == current branch (GH#519)
func TestIsSyncBranchSameAsCurrent(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
ctx := context.Background()
t.Run("returns true when sync branch equals current branch", func(t *testing.T) {
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
// Create initial commit so we can get current branch
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "initial commit")
// Get current branch name
currentBranch := strings.TrimSpace(getGitOutput(t, repoDir, "symbolic-ref", "--short", "HEAD"))
// Save original dir and change to test repo
origDir, _ := os.Getwd()
os.Chdir(repoDir)
defer os.Chdir(origDir)
// Should return true when sync branch == current branch
if !IsSyncBranchSameAsCurrent(ctx, currentBranch) {
t.Errorf("IsSyncBranchSameAsCurrent(%q) = false, want true", currentBranch)
}
})
t.Run("returns false when sync branch differs from current branch", func(t *testing.T) {
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
// Create initial commit
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "initial commit")
// Save original dir and change to test repo
origDir, _ := os.Getwd()
os.Chdir(repoDir)
defer os.Chdir(origDir)
// Should return false when sync branch != current branch
if IsSyncBranchSameAsCurrent(ctx, "beads-sync") {
t.Error("IsSyncBranchSameAsCurrent(\"beads-sync\") = true, want false")
}
})
t.Run("returns false on error getting current branch", func(t *testing.T) {
// Test in a non-git directory
tmpDir, _ := os.MkdirTemp("", "non-git-*")
defer os.RemoveAll(tmpDir)
origDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(origDir)
// Should return false when not in a git repo
if IsSyncBranchSameAsCurrent(ctx, "any-branch") {
t.Error("IsSyncBranchSameAsCurrent in non-git dir = true, want false")
}
})
}
// TestContentMergeRecoveryPreservesTombstones tests that contentMergeRecovery
// uses content-level merge which properly preserves tombstones.
// This is the fix for bd-kpy: Sync race where rebase-based divergence recovery
// resurrects tombstones.
func TestContentMergeRecoveryPreservesTombstones(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
ctx := context.Background()
t.Run("tombstone is preserved during merge recovery", func(t *testing.T) {
// This test simulates the race condition described in bd-kpy:
// 1. Repo A deletes issue (creates tombstone), pushes successfully
// 2. Repo B (with 'closed' status) tries to push, fails (non-fast-forward)
// 3. Repo B uses contentMergeRecovery
// 4. Verify tombstone from A is preserved, not overwritten by B's 'closed'
// Create a bare remote repo
remoteDir, err := os.MkdirTemp("", "bd-test-remote-*")
if err != nil {
t.Fatalf("Failed to create remote dir: %v", err)
}
defer os.RemoveAll(remoteDir)
runGit(t, remoteDir, "init", "--bare")
// Create local repo
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
runGit(t, repoDir, "remote", "add", "origin", remoteDir)
syncBranch := "beads-sync"
jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl")
// Create base commit: issue with status="closed"
runGit(t, repoDir, "checkout", "-b", syncBranch)
writeFile(t, jsonlPath, `{"id":"test-1","status":"closed","title":"Test Issue"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "base: issue closed")
// Push base to remote
runGit(t, repoDir, "push", "-u", "origin", syncBranch)
baseCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
// Create tombstone commit (simulating what Repo A did)
writeFile(t, jsonlPath, `{"id":"test-1","status":"tombstone","title":"Test Issue"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "remote: issue tombstoned")
// Push tombstone to remote (simulating Repo A's successful push)
runGit(t, repoDir, "push", "origin", syncBranch)
// Now simulate Repo B: reset to base and make different changes
runGit(t, repoDir, "reset", "--hard", baseCommit)
writeFile(t, jsonlPath, `{"id":"test-1","status":"closed","title":"Test Issue","notes":"local change"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "local: added notes")
// Now local has diverged from remote:
// - Local HEAD: status=closed with notes
// - Remote origin/beads-sync: status=tombstone
// - Common ancestor (base): status=closed
// Run contentMergeRecovery - this should use content-level merge
err = contentMergeRecovery(ctx, repoDir, syncBranch, "origin")
if err != nil {
t.Fatalf("contentMergeRecovery() error = %v", err)
}
// Read the merged content
mergedData, err := os.ReadFile(jsonlPath)
if err != nil {
t.Fatalf("Failed to read merged JSONL: %v", err)
}
merged := string(mergedData)
// The tombstone should be preserved!
// The 3-way merge should see:
// base: status=closed
// local: status=closed (unchanged)
// remote: status=tombstone (changed)
// Result: status=tombstone (remote wins because it changed)
if !strings.Contains(merged, `"status":"tombstone"`) {
t.Errorf("contentMergeRecovery() did not preserve tombstone.\nGot: %s\nWant: status=tombstone", merged)
}
})
t.Run("both sides tombstone results in tombstone", func(t *testing.T) {
// Create a bare remote repo
remoteDir, err := os.MkdirTemp("", "bd-test-remote-*")
if err != nil {
t.Fatalf("Failed to create remote dir: %v", err)
}
defer os.RemoveAll(remoteDir)
runGit(t, remoteDir, "init", "--bare")
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
runGit(t, repoDir, "remote", "add", "origin", remoteDir)
syncBranch := "beads-sync"
jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl")
// Create base: issue open
runGit(t, repoDir, "checkout", "-b", syncBranch)
writeFile(t, jsonlPath, `{"id":"test-1","status":"open","title":"Test"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "base")
runGit(t, repoDir, "push", "-u", "origin", syncBranch)
baseCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
// Remote: tombstone (push to remote)
writeFile(t, jsonlPath, `{"id":"test-1","status":"tombstone","title":"Test"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "remote tombstone")
runGit(t, repoDir, "push", "origin", syncBranch)
// Local: reset and also tombstone (both deleted independently)
runGit(t, repoDir, "reset", "--hard", baseCommit)
writeFile(t, jsonlPath, `{"id":"test-1","status":"tombstone","title":"Test"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "local tombstone")
err = contentMergeRecovery(ctx, repoDir, syncBranch, "origin")
if err != nil {
t.Fatalf("contentMergeRecovery() error = %v", err)
}
mergedData, _ := os.ReadFile(jsonlPath)
if !strings.Contains(string(mergedData), `"status":"tombstone"`) {
t.Errorf("Expected tombstone to be preserved, got: %s", mergedData)
}
})
}