bd sync: 2025-11-25 11:46:06
This commit is contained in:
@@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/deletions"
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
@@ -51,6 +53,8 @@ type Result struct {
|
||||
ExpectedPrefix string // Database configured prefix
|
||||
MismatchPrefixes map[string]int // Map of mismatched prefixes to count
|
||||
SkippedDependencies []string // Dependencies skipped due to FK constraint violations
|
||||
Purged int // Issues purged from DB (found in deletions manifest)
|
||||
PurgedIDs []string // IDs that were purged
|
||||
}
|
||||
|
||||
// ImportIssues handles the core import logic used by both manual and auto-import.
|
||||
@@ -144,6 +148,15 @@ func ImportIssues(ctx context.Context, dbPath string, store storage.Storage, iss
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Purge deleted issues from DB based on deletions manifest
|
||||
// Issues that are in the manifest but not in JSONL should be deleted from DB
|
||||
if !opts.DryRun {
|
||||
if err := purgeDeletedIssues(ctx, sqliteStore, dbPath, issues, result); err != nil {
|
||||
// Non-fatal - just log warning
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to purge deleted issues: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Checkpoint WAL to ensure data persistence and reduce WAL file size
|
||||
if err := sqliteStore.CheckpointWAL(ctx); err != nil {
|
||||
// Non-fatal - just log warning
|
||||
@@ -738,6 +751,73 @@ func importComments(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issu
|
||||
return nil
|
||||
}
|
||||
|
||||
// purgeDeletedIssues removes issues from the DB that are in the deletions manifest
|
||||
// but not in the incoming JSONL. This enables deletion propagation across clones.
|
||||
func purgeDeletedIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, dbPath string, jsonlIssues []*types.Issue, result *Result) error {
|
||||
// Get deletions manifest path (same directory as database)
|
||||
beadsDir := filepath.Dir(dbPath)
|
||||
deletionsPath := deletions.DefaultPath(beadsDir)
|
||||
|
||||
// Load deletions manifest (gracefully handles missing/empty file)
|
||||
loadResult, err := deletions.LoadDeletions(deletionsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load deletions manifest: %w", err)
|
||||
}
|
||||
|
||||
// Log any warnings from loading
|
||||
for _, warning := range loadResult.Warnings {
|
||||
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
|
||||
}
|
||||
|
||||
// If no deletions, nothing to do
|
||||
if len(loadResult.Records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build set of IDs in the incoming JSONL for O(1) lookup
|
||||
jsonlIDs := make(map[string]bool, len(jsonlIssues))
|
||||
for _, issue := range jsonlIssues {
|
||||
jsonlIDs[issue.ID] = true
|
||||
}
|
||||
|
||||
// Get all DB issues
|
||||
dbIssues, err := sqliteStore.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get DB issues: %w", err)
|
||||
}
|
||||
|
||||
// Find DB issues that:
|
||||
// 1. Are NOT in the JSONL (not synced from remote)
|
||||
// 2. ARE in the deletions manifest (were deleted elsewhere)
|
||||
for _, dbIssue := range dbIssues {
|
||||
if jsonlIDs[dbIssue.ID] {
|
||||
// Issue is in JSONL, keep it
|
||||
continue
|
||||
}
|
||||
|
||||
if del, found := loadResult.Records[dbIssue.ID]; found {
|
||||
// Issue is in deletions manifest - purge it from DB
|
||||
if err := sqliteStore.DeleteIssue(ctx, dbIssue.ID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to purge %s: %v\n", dbIssue.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Log the purge with metadata
|
||||
fmt.Fprintf(os.Stderr, "Purged %s (deleted %s by %s", dbIssue.ID, del.Timestamp.Format("2006-01-02 15:04:05"), del.Actor)
|
||||
if del.Reason != "" {
|
||||
fmt.Fprintf(os.Stderr, ", reason: %s", del.Reason)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, ")\n")
|
||||
|
||||
result.Purged++
|
||||
result.PurgedIDs = append(result.PurgedIDs, dbIssue.ID)
|
||||
}
|
||||
// If not in JSONL and not in deletions manifest, keep it (local work)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func GetPrefixList(prefixes map[string]int) []string {
|
||||
|
||||
233
internal/importer/purge_test.go
Normal file
233
internal/importer/purge_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
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 purged 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, result); err != nil {
|
||||
t.Fatalf("purgeDeletedIssues failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify issue2 was purged
|
||||
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 gone from database
|
||||
iss2, err := store.GetIssue(ctx, "test-def")
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
if iss2 != nil {
|
||||
t.Errorf("expected issue2 to be deleted, but it still exists")
|
||||
}
|
||||
|
||||
// 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, 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_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, 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user