Files
beads/internal/importer/purge_test.go
Rod Davenport 6985ea94e5 fix(sync): protect local issues from git-history-backfill during sync (#485)
Fix sync bug where newly created issues were incorrectly tombstoned during bd sync.

The root cause was git-history-backfill finding issues in local commits on the sync branch, then tombstoning them when they weren't in the merged JSONL. The fix protects issues from the left snapshot (local export) from git-history-backfill.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2025-12-12 13:28:48 -08:00

352 lines
10 KiB
Go

package importer
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/beads/internal/deletions"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// TestPurgeDeletedIssues tests that issues in the deletions manifest are converted to tombstones during import
func TestPurgeDeletedIssues(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
// Create database
dbPath := filepath.Join(tmpDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer store.Close()
// Initialize prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("failed to set prefix: %v", err)
}
// Create some issues in the database
issue1 := &types.Issue{
ID: "test-abc",
Title: "Issue 1",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue2 := &types.Issue{
ID: "test-def",
Title: "Issue 2",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue3 := &types.Issue{
ID: "test-ghi",
Title: "Issue 3 (local work)",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
for _, iss := range []*types.Issue{issue1, issue2, issue3} {
if err := store.CreateIssue(ctx, iss, "test"); err != nil {
t.Fatalf("failed to create issue %s: %v", iss.ID, err)
}
}
// Create a deletions manifest with issue2 deleted
deletionsPath := deletions.DefaultPath(tmpDir)
delRecord := deletions.DeletionRecord{
ID: "test-def",
Timestamp: time.Now().UTC(),
Actor: "test-user",
Reason: "test deletion",
}
if err := deletions.AppendDeletion(deletionsPath, delRecord); err != nil {
t.Fatalf("failed to create deletions manifest: %v", err)
}
// Simulate import with only issue1 in the JSONL (issue2 was deleted, issue3 is local work)
jsonlIssues := []*types.Issue{issue1}
result := &Result{
IDMapping: make(map[string]string),
MismatchPrefixes: make(map[string]int),
}
// Call purgeDeletedIssues
if err := purgeDeletedIssues(ctx, store, dbPath, jsonlIssues, Options{}, result); err != nil {
t.Fatalf("purgeDeletedIssues failed: %v", err)
}
// Verify issue2 was tombstoned (bd-dve: now converts to tombstone instead of hard-delete)
if result.Purged != 1 {
t.Errorf("expected 1 purged issue, got %d", result.Purged)
}
if len(result.PurgedIDs) != 1 || result.PurgedIDs[0] != "test-def" {
t.Errorf("expected PurgedIDs to contain 'test-def', got %v", result.PurgedIDs)
}
// Verify issue2 is now a tombstone (not hard-deleted)
// GetIssue returns nil for tombstones by default, so use IncludeTombstones filter
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{IncludeTombstones: true})
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
var iss2 *types.Issue
for _, iss := range issues {
if iss.ID == "test-def" {
iss2 = iss
break
}
}
if iss2 == nil {
t.Errorf("expected issue2 to exist as tombstone, but it was hard-deleted")
} else if iss2.Status != types.StatusTombstone {
t.Errorf("expected issue2 to be a tombstone, got status %q", iss2.Status)
}
// Verify issue1 still exists (in JSONL)
iss1, err := store.GetIssue(ctx, "test-abc")
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if iss1 == nil {
t.Errorf("expected issue1 to still exist")
}
// Verify issue3 still exists (local work, not in deletions manifest)
iss3, err := store.GetIssue(ctx, "test-ghi")
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if iss3 == nil {
t.Errorf("expected issue3 (local work) to still exist")
}
}
// TestPurgeDeletedIssues_NoDeletionsManifest tests that import works without a deletions manifest
func TestPurgeDeletedIssues_NoDeletionsManifest(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
// Create database
dbPath := filepath.Join(tmpDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer store.Close()
// Initialize prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("failed to set prefix: %v", err)
}
// Create an issue in the database
issue := &types.Issue{
ID: "test-abc",
Title: "Issue 1",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// No deletions manifest exists
jsonlIssues := []*types.Issue{issue}
result := &Result{
IDMapping: make(map[string]string),
MismatchPrefixes: make(map[string]int),
}
// Call purgeDeletedIssues - should succeed with no errors
if err := purgeDeletedIssues(ctx, store, dbPath, jsonlIssues, Options{}, result); err != nil {
t.Fatalf("purgeDeletedIssues failed: %v", err)
}
// Verify nothing was purged
if result.Purged != 0 {
t.Errorf("expected 0 purged issues, got %d", result.Purged)
}
// Verify issue still exists
iss, err := store.GetIssue(ctx, "test-abc")
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if iss == nil {
t.Errorf("expected issue to still exist")
}
}
// TestPurgeDeletedIssues_ProtectLocalExportIDs tests that issues in ProtectLocalExportIDs
// are not tombstoned even if they're not in the JSONL (bd-sync-deletion fix)
func TestPurgeDeletedIssues_ProtectLocalExportIDs(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
// Create database
dbPath := filepath.Join(tmpDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer store.Close()
// Initialize prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("failed to set prefix: %v", err)
}
// Create issues in the database:
// - issue1: in JSONL (should survive)
// - issue2: NOT in JSONL, but in ProtectLocalExportIDs (should survive - this is the fix)
// - issue3: NOT in JSONL, NOT protected (would be checked by git-history, but we skip that)
issue1 := &types.Issue{
ID: "test-abc",
Title: "Issue 1 (in JSONL)",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue2 := &types.Issue{
ID: "test-def",
Title: "Issue 2 (protected local export)",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue3 := &types.Issue{
ID: "test-ghi",
Title: "Issue 3 (unprotected)",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
for _, iss := range []*types.Issue{issue1, issue2, issue3} {
if err := store.CreateIssue(ctx, iss, "test"); err != nil {
t.Fatalf("failed to create issue %s: %v", iss.ID, err)
}
}
// Simulate import where JSONL only has issue1 (issue2 was in our local export but lost during merge)
jsonlIssues := []*types.Issue{issue1}
result := &Result{
IDMapping: make(map[string]string),
MismatchPrefixes: make(map[string]int),
}
// Set ProtectLocalExportIDs to protect issue2 (simulates left snapshot protection)
opts := Options{
ProtectLocalExportIDs: map[string]bool{
"test-def": true, // Protect issue2
},
NoGitHistory: true, // Skip git history check for this test
}
// Call purgeDeletedIssues
if err := purgeDeletedIssues(ctx, store, dbPath, jsonlIssues, opts, result); err != nil {
t.Fatalf("purgeDeletedIssues failed: %v", err)
}
// Verify issue2 was preserved (the fix!)
if result.PreservedLocalExport != 1 {
t.Errorf("expected 1 preserved issue, got %d", result.PreservedLocalExport)
}
if len(result.PreservedLocalIDs) != 1 || result.PreservedLocalIDs[0] != "test-def" {
t.Errorf("expected PreservedLocalIDs to contain 'test-def', got %v", result.PreservedLocalIDs)
}
// Verify issue1 still exists (was in JSONL)
iss1, err := store.GetIssue(ctx, "test-abc")
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if iss1 == nil {
t.Errorf("expected issue1 to still exist")
}
// Verify issue2 still exists (was protected)
iss2, err := store.GetIssue(ctx, "test-def")
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if iss2 == nil {
t.Errorf("expected issue2 (protected local export) to still exist - THIS IS THE FIX")
}
// Verify issue3 still exists (not in deletions, git history check skipped)
iss3, err := store.GetIssue(ctx, "test-ghi")
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if iss3 == nil {
t.Errorf("expected issue3 to still exist (git history check skipped)")
}
}
// TestPurgeDeletedIssues_EmptyDeletionsManifest tests that import works with empty deletions manifest
func TestPurgeDeletedIssues_EmptyDeletionsManifest(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
// Create database
dbPath := filepath.Join(tmpDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer store.Close()
// Initialize prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("failed to set prefix: %v", err)
}
// Create an issue in the database
issue := &types.Issue{
ID: "test-abc",
Title: "Issue 1",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Create empty deletions manifest
deletionsPath := deletions.DefaultPath(tmpDir)
if err := os.WriteFile(deletionsPath, []byte{}, 0644); err != nil {
t.Fatalf("failed to create empty deletions manifest: %v", err)
}
jsonlIssues := []*types.Issue{issue}
result := &Result{
IDMapping: make(map[string]string),
MismatchPrefixes: make(map[string]int),
}
// Call purgeDeletedIssues - should succeed with no errors
if err := purgeDeletedIssues(ctx, store, dbPath, jsonlIssues, Options{}, result); err != nil {
t.Fatalf("purgeDeletedIssues failed: %v", err)
}
// Verify nothing was purged
if result.Purged != 0 {
t.Errorf("expected 0 purged issues, got %d", result.Purged)
}
}