Files
beads/cmd/bd/deletion_tracking_test.go
Steve Yegge 708a81c491 Fix bd-hv01: Implement deletion tracking for multi-workspace sync
- Add 3-way merge deletion tracking using snapshot files
- Create .beads/beads.base.jsonl and .beads/beads.left.jsonl snapshots
- Integrate into both sync.go and daemon_sync.go
- Add comprehensive test suite in deletion_tracking_test.go
- Update .gitignore to exclude snapshot files

This fixes the resurrection bug where deleted issues come back after
multi-workspace git sync. Uses the beads-merge 3-way merge logic to
detect and apply deletions correctly.
2025-11-06 17:52:37 -08:00

388 lines
12 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// TestMultiWorkspaceDeletionSync simulates the bd-hv01 bug scenario:
// Clone A deletes an issue, Clone B still has it, and after sync it should stay deleted
func TestMultiWorkspaceDeletionSync(t *testing.T) {
// Setup two separate workspaces simulating two git clones
cloneADir := t.TempDir()
cloneBDir := t.TempDir()
cloneAJSONL := filepath.Join(cloneADir, "beads.jsonl")
cloneBJSONL := filepath.Join(cloneBDir, "beads.jsonl")
cloneADB := filepath.Join(cloneADir, "beads.db")
cloneBDB := filepath.Join(cloneBDir, "beads.db")
ctx := context.Background()
// Create stores for both clones
storeA, err := sqlite.New(cloneADB)
if err != nil {
t.Fatalf("Failed to create store A: %v", err)
}
defer storeA.Close()
if err := storeA.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set issue_prefix for store A: %v", err)
}
storeB, err := sqlite.New(cloneBDB)
if err != nil {
t.Fatalf("Failed to create store B: %v", err)
}
defer storeB.Close()
if err := storeB.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set issue_prefix for store B: %v", err)
}
// Step 1: Both clones start with the same two issues
issueToDelete := &types.Issue{
ID: "bd-delete-me",
Title: "Issue to be deleted",
Description: "This will be deleted in clone A",
Status: types.StatusOpen,
Priority: 1,
IssueType: "bug",
}
issueToKeep := &types.Issue{
ID: "bd-keep-me",
Title: "Issue to keep",
Description: "This should remain",
Status: types.StatusOpen,
Priority: 1,
IssueType: "feature",
}
// Create in both stores (using "test" as actor)
if err := storeA.CreateIssue(ctx, issueToDelete, "test"); err != nil {
t.Fatalf("Failed to create issue in store A: %v", err)
}
if err := storeA.CreateIssue(ctx, issueToKeep, "test"); err != nil {
t.Fatalf("Failed to create issue in store A: %v", err)
}
if err := storeB.CreateIssue(ctx, issueToDelete, "test"); err != nil {
t.Fatalf("Failed to create issue in store B: %v", err)
}
if err := storeB.CreateIssue(ctx, issueToKeep, "test"); err != nil {
t.Fatalf("Failed to create issue in store B: %v", err)
}
// Export from both
if err := exportToJSONLWithStore(ctx, storeA, cloneAJSONL); err != nil {
t.Fatalf("Failed to export from store A: %v", err)
}
if err := exportToJSONLWithStore(ctx, storeB, cloneBJSONL); err != nil {
t.Fatalf("Failed to export from store B: %v", err)
}
// Initialize base snapshots for both (simulating first sync)
if err := initializeSnapshotsIfNeeded(cloneAJSONL); err != nil {
t.Fatalf("Failed to initialize snapshots for A: %v", err)
}
if err := initializeSnapshotsIfNeeded(cloneBJSONL); err != nil {
t.Fatalf("Failed to initialize snapshots for B: %v", err)
}
// Step 2: Clone A deletes the issue
if err := storeA.DeleteIssue(ctx, "bd-delete-me"); err != nil {
t.Fatalf("Failed to delete issue in store A: %v", err)
}
// Step 3: Clone A exports and captures left snapshot (simulating pre-pull)
if err := exportToJSONLWithStore(ctx, storeA, cloneAJSONL); err != nil {
t.Fatalf("Failed to export from store A after deletion: %v", err)
}
if err := captureLeftSnapshot(cloneAJSONL); err != nil {
t.Fatalf("Failed to capture left snapshot for A: %v", err)
}
// Simulate git push/pull: Copy Clone A's JSONL to Clone B's "remote" state
remoteJSONL := cloneAJSONL
// Step 4: Clone B exports (still has both issues) and captures left snapshot
if err := exportToJSONLWithStore(ctx, storeB, cloneBJSONL); err != nil {
t.Fatalf("Failed to export from store B: %v", err)
}
if err := captureLeftSnapshot(cloneBJSONL); err != nil {
t.Fatalf("Failed to capture left snapshot for B: %v", err)
}
// Step 5: Simulate Clone B pulling from remote (copy remote JSONL)
remoteData, err := os.ReadFile(remoteJSONL)
if err != nil {
t.Fatalf("Failed to read remote JSONL: %v", err)
}
if err := os.WriteFile(cloneBJSONL, remoteData, 0644); err != nil {
t.Fatalf("Failed to write pulled JSONL to clone B: %v", err)
}
// Step 6: Clone B applies 3-way merge and prunes deletions
// This is the key fix - it should detect that bd-delete-me was deleted remotely
merged, err := merge3WayAndPruneDeletions(ctx, storeB, cloneBJSONL)
if err != nil {
t.Fatalf("Failed to apply deletions from merge: %v", err)
}
if !merged {
t.Error("Expected 3-way merge to run, but it was skipped")
}
// Step 7: Verify the deletion was applied to Clone B's database
deletedIssue, err := storeB.GetIssue(ctx, "bd-delete-me")
if err == nil && deletedIssue != nil {
t.Errorf("Issue bd-delete-me should have been deleted from Clone B, but still exists")
}
// Verify the kept issue still exists
keptIssue, err := storeB.GetIssue(ctx, "bd-keep-me")
if err != nil || keptIssue == nil {
t.Errorf("Issue bd-keep-me should still exist in Clone B")
}
// Verify Clone A still has only one issue
issuesA, err := storeA.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatalf("Failed to search issues in store A: %v", err)
}
if len(issuesA) != 1 {
t.Errorf("Clone A should have 1 issue after deletion, got %d", len(issuesA))
}
// Verify Clone B now matches Clone A (both have 1 issue)
issuesB, err := storeB.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatalf("Failed to search issues in store B: %v", err)
}
if len(issuesB) != 1 {
t.Errorf("Clone B should have 1 issue after merge, got %d", len(issuesB))
}
}
// TestDeletionWithLocalModification tests the conflict scenario:
// Remote deletes an issue, but local has modified it
func TestDeletionWithLocalModification(t *testing.T) {
dir := t.TempDir()
jsonlPath := filepath.Join(dir, "beads.jsonl")
dbPath := filepath.Join(dir, "beads.db")
ctx := context.Background()
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
// Create an issue
issue := &types.Issue{
ID: "bd-conflict",
Title: "Original title",
Description: "Original description",
Status: types.StatusOpen,
Priority: 1,
IssueType: "bug",
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Export and create base snapshot
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Failed to export: %v", err)
}
if err := initializeSnapshotsIfNeeded(jsonlPath); err != nil {
t.Fatalf("Failed to initialize snapshots: %v", err)
}
// Modify the issue locally
updates := map[string]interface{}{
"title": "Modified title locally",
}
if err := store.UpdateIssue(ctx, "bd-conflict", updates, "test"); err != nil {
t.Fatalf("Failed to update issue: %v", err)
}
// Export modified state and capture left snapshot
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Failed to export after modification: %v", err)
}
if err := captureLeftSnapshot(jsonlPath); err != nil {
t.Fatalf("Failed to capture left snapshot: %v", err)
}
// Simulate remote deletion (write empty JSONL)
if err := os.WriteFile(jsonlPath, []byte(""), 0644); err != nil {
t.Fatalf("Failed to simulate remote deletion: %v", err)
}
// Try to merge - this should detect a conflict (modified locally, deleted remotely)
_, err = merge3WayAndPruneDeletions(ctx, store, jsonlPath)
if err == nil {
t.Error("Expected merge conflict error, but got nil")
}
// The issue should still exist in the database (conflict not auto-resolved)
conflictIssue, err := store.GetIssue(ctx, "bd-conflict")
if err != nil || conflictIssue == nil {
t.Error("Issue should still exist after conflict")
}
}
// TestComputeAcceptedDeletions tests the deletion detection logic
func TestComputeAcceptedDeletions(t *testing.T) {
dir := t.TempDir()
basePath := filepath.Join(dir, "base.jsonl")
leftPath := filepath.Join(dir, "left.jsonl")
mergedPath := filepath.Join(dir, "merged.jsonl")
// Base has 3 issues
baseContent := `{"id":"bd-1","title":"Issue 1"}
{"id":"bd-2","title":"Issue 2"}
{"id":"bd-3","title":"Issue 3"}
`
// Left has 3 issues (unchanged from base)
leftContent := baseContent
// Merged has only 2 issues (bd-2 was deleted remotely)
mergedContent := `{"id":"bd-1","title":"Issue 1"}
{"id":"bd-3","title":"Issue 3"}
`
if err := os.WriteFile(basePath, []byte(baseContent), 0644); err != nil {
t.Fatalf("Failed to write base: %v", err)
}
if err := os.WriteFile(leftPath, []byte(leftContent), 0644); err != nil {
t.Fatalf("Failed to write left: %v", err)
}
if err := os.WriteFile(mergedPath, []byte(mergedContent), 0644); err != nil {
t.Fatalf("Failed to write merged: %v", err)
}
deletions, err := computeAcceptedDeletions(basePath, leftPath, mergedPath)
if err != nil {
t.Fatalf("Failed to compute deletions: %v", err)
}
if len(deletions) != 1 {
t.Errorf("Expected 1 deletion, got %d", len(deletions))
}
if len(deletions) > 0 && deletions[0] != "bd-2" {
t.Errorf("Expected deletion of bd-2, got %s", deletions[0])
}
}
// TestComputeAcceptedDeletions_LocallyModified tests that locally modified issues are not deleted
func TestComputeAcceptedDeletions_LocallyModified(t *testing.T) {
dir := t.TempDir()
basePath := filepath.Join(dir, "base.jsonl")
leftPath := filepath.Join(dir, "left.jsonl")
mergedPath := filepath.Join(dir, "merged.jsonl")
// Base has 2 issues
baseContent := `{"id":"bd-1","title":"Original 1"}
{"id":"bd-2","title":"Original 2"}
`
// Left has bd-2 modified locally
leftContent := `{"id":"bd-1","title":"Original 1"}
{"id":"bd-2","title":"Modified locally"}
`
// Merged has only bd-1 (bd-2 deleted remotely, but we modified it locally)
mergedContent := `{"id":"bd-1","title":"Original 1"}
`
if err := os.WriteFile(basePath, []byte(baseContent), 0644); err != nil {
t.Fatalf("Failed to write base: %v", err)
}
if err := os.WriteFile(leftPath, []byte(leftContent), 0644); err != nil {
t.Fatalf("Failed to write left: %v", err)
}
if err := os.WriteFile(mergedPath, []byte(mergedContent), 0644); err != nil {
t.Fatalf("Failed to write merged: %v", err)
}
deletions, err := computeAcceptedDeletions(basePath, leftPath, mergedPath)
if err != nil {
t.Fatalf("Failed to compute deletions: %v", err)
}
// bd-2 should NOT be in accepted deletions because it was modified locally
if len(deletions) != 0 {
t.Errorf("Expected 0 deletions (locally modified), got %d: %v", len(deletions), deletions)
}
}
// TestSnapshotManagement tests the snapshot file lifecycle
func TestSnapshotManagement(t *testing.T) {
dir := t.TempDir()
jsonlPath := filepath.Join(dir, "beads.jsonl")
// Write initial JSONL
content := `{"id":"bd-1","title":"Test"}
`
if err := os.WriteFile(jsonlPath, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write JSONL: %v", err)
}
// Initialize snapshots
if err := initializeSnapshotsIfNeeded(jsonlPath); err != nil {
t.Fatalf("Failed to initialize snapshots: %v", err)
}
basePath, leftPath := getSnapshotPaths(jsonlPath)
// Base should exist, left should not
if !fileExists(basePath) {
t.Error("Base snapshot should exist after initialization")
}
if fileExists(leftPath) {
t.Error("Left snapshot should not exist yet")
}
// Capture left snapshot
if err := captureLeftSnapshot(jsonlPath); err != nil {
t.Fatalf("Failed to capture left snapshot: %v", err)
}
if !fileExists(leftPath) {
t.Error("Left snapshot should exist after capture")
}
// Update base snapshot
if err := updateBaseSnapshot(jsonlPath); err != nil {
t.Fatalf("Failed to update base snapshot: %v", err)
}
// Both should exist now
baseCount, leftCount, baseExists, leftExists := getSnapshotStats(jsonlPath)
if !baseExists || !leftExists {
t.Error("Both snapshots should exist")
}
if baseCount != 1 || leftCount != 1 {
t.Errorf("Expected 1 issue in each snapshot, got base=%d left=%d", baseCount, leftCount)
}
}