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)
352 lines
10 KiB
Go
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)
|
|
}
|
|
}
|