refactor: remove all deletions.jsonl code (bd-fom)
Complete removal of the legacy deletions.jsonl manifest system. Tombstones are now the sole deletion mechanism. Removed: - internal/deletions/ - entire package - cmd/bd/deleted.go - deleted command - cmd/bd/doctor/fix/deletions.go - HydrateDeletionsManifest - Tests for all removed functionality Cleaned: - cmd/bd/sync.go - removed sanitize, auto-compact - cmd/bd/delete.go - removed dual-writes - cmd/bd/doctor.go - removed checkDeletionsManifest - internal/importer/importer.go - removed deletions checks - internal/syncbranch/worktree.go - removed deletions merge - cmd/bd/integrity.go - updated validation (warn-only on decrease) Files removed: 12 Lines removed: ~7500 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -577,657 +577,6 @@ func TestZFCSkipsExportAfterImport(t *testing.T) {
|
||||
t.Logf("✓ ZFC fix verified: DB synced from 100 to 10 issues, JSONL unchanged")
|
||||
}
|
||||
|
||||
func TestMaybeAutoCompactDeletions_Disabled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create test database
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create beads dir: %v", err)
|
||||
}
|
||||
|
||||
testDBPath := filepath.Join(beadsDir, "beads.db")
|
||||
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
|
||||
|
||||
// Create store
|
||||
testStore, err := sqlite.New(ctx, testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
defer testStore.Close()
|
||||
|
||||
// Set global store for maybeAutoCompactDeletions
|
||||
// Save and restore original values
|
||||
originalStore := store
|
||||
originalStoreActive := storeActive
|
||||
defer func() {
|
||||
store = originalStore
|
||||
storeActive = originalStoreActive
|
||||
}()
|
||||
|
||||
store = testStore
|
||||
storeActive = true
|
||||
|
||||
// Create empty JSONL file
|
||||
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to create JSONL: %v", err)
|
||||
}
|
||||
|
||||
// Auto-compact is disabled by default, so should return nil
|
||||
err = maybeAutoCompactDeletions(ctx, jsonlPath)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error when auto-compact disabled, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeAutoCompactDeletions_Enabled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create test database
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create beads dir: %v", err)
|
||||
}
|
||||
|
||||
testDBPath := filepath.Join(beadsDir, "beads.db")
|
||||
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
|
||||
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
||||
|
||||
// Create store
|
||||
testStore, err := sqlite.New(ctx, testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
defer testStore.Close()
|
||||
|
||||
// Enable auto-compact with low threshold
|
||||
if err := testStore.SetConfig(ctx, "deletions.auto_compact", "true"); err != nil {
|
||||
t.Fatalf("failed to set auto_compact config: %v", err)
|
||||
}
|
||||
if err := testStore.SetConfig(ctx, "deletions.auto_compact_threshold", "5"); err != nil {
|
||||
t.Fatalf("failed to set threshold config: %v", err)
|
||||
}
|
||||
if err := testStore.SetConfig(ctx, "deletions.retention_days", "1"); err != nil {
|
||||
t.Fatalf("failed to set retention config: %v", err)
|
||||
}
|
||||
|
||||
// Set global store for maybeAutoCompactDeletions
|
||||
// Save and restore original values
|
||||
originalStore := store
|
||||
originalStoreActive := storeActive
|
||||
defer func() {
|
||||
store = originalStore
|
||||
storeActive = originalStoreActive
|
||||
}()
|
||||
|
||||
store = testStore
|
||||
storeActive = true
|
||||
|
||||
// Create empty JSONL file
|
||||
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to create JSONL: %v", err)
|
||||
}
|
||||
|
||||
// Create deletions file with entries (some old, some recent)
|
||||
now := time.Now()
|
||||
deletionsContent := ""
|
||||
// Add 10 old entries (will be pruned)
|
||||
for i := 0; i < 10; i++ {
|
||||
oldTime := now.AddDate(0, 0, -10).Format(time.RFC3339)
|
||||
deletionsContent += fmt.Sprintf(`{"id":"bd-old-%d","ts":"%s","by":"user"}`, i, oldTime) + "\n"
|
||||
}
|
||||
// Add 3 recent entries (will be kept)
|
||||
for i := 0; i < 3; i++ {
|
||||
recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
deletionsContent += fmt.Sprintf(`{"id":"bd-recent-%d","ts":"%s","by":"user"}`, i, recentTime) + "\n"
|
||||
}
|
||||
|
||||
if err := os.WriteFile(deletionsPath, []byte(deletionsContent), 0644); err != nil {
|
||||
t.Fatalf("failed to create deletions file: %v", err)
|
||||
}
|
||||
|
||||
// Verify initial count
|
||||
initialCount := strings.Count(deletionsContent, "\n")
|
||||
if initialCount != 13 {
|
||||
t.Fatalf("expected 13 initial entries, got %d", initialCount)
|
||||
}
|
||||
|
||||
// Run auto-compact
|
||||
err = maybeAutoCompactDeletions(ctx, jsonlPath)
|
||||
if err != nil {
|
||||
t.Errorf("auto-compact failed: %v", err)
|
||||
}
|
||||
|
||||
// Read deletions file and count remaining entries
|
||||
afterContent, err := os.ReadFile(deletionsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read deletions file: %v", err)
|
||||
}
|
||||
|
||||
afterLines := strings.Split(strings.TrimSpace(string(afterContent)), "\n")
|
||||
afterCount := 0
|
||||
for _, line := range afterLines {
|
||||
if line != "" {
|
||||
afterCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Should have pruned old entries, kept recent ones
|
||||
if afterCount != 3 {
|
||||
t.Errorf("expected 3 entries after prune (recent ones), got %d", afterCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeAutoCompactDeletions_BelowThreshold(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create test database
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create beads dir: %v", err)
|
||||
}
|
||||
|
||||
testDBPath := filepath.Join(beadsDir, "beads.db")
|
||||
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
|
||||
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
||||
|
||||
// Create store
|
||||
testStore, err := sqlite.New(ctx, testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
defer testStore.Close()
|
||||
|
||||
// Enable auto-compact with high threshold
|
||||
if err := testStore.SetConfig(ctx, "deletions.auto_compact", "true"); err != nil {
|
||||
t.Fatalf("failed to set auto_compact config: %v", err)
|
||||
}
|
||||
if err := testStore.SetConfig(ctx, "deletions.auto_compact_threshold", "100"); err != nil {
|
||||
t.Fatalf("failed to set threshold config: %v", err)
|
||||
}
|
||||
|
||||
// Set global store for maybeAutoCompactDeletions
|
||||
// Save and restore original values
|
||||
originalStore := store
|
||||
originalStoreActive := storeActive
|
||||
defer func() {
|
||||
store = originalStore
|
||||
storeActive = originalStoreActive
|
||||
}()
|
||||
|
||||
store = testStore
|
||||
storeActive = true
|
||||
|
||||
// Create empty JSONL file
|
||||
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to create JSONL: %v", err)
|
||||
}
|
||||
|
||||
// Create deletions file with only 5 entries (below threshold of 100)
|
||||
now := time.Now()
|
||||
deletionsContent := ""
|
||||
for i := 0; i < 5; i++ {
|
||||
ts := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
deletionsContent += fmt.Sprintf(`{"id":"bd-%d","ts":"%s","by":"user"}`, i, ts) + "\n"
|
||||
}
|
||||
|
||||
if err := os.WriteFile(deletionsPath, []byte(deletionsContent), 0644); err != nil {
|
||||
t.Fatalf("failed to create deletions file: %v", err)
|
||||
}
|
||||
|
||||
// Run auto-compact - should skip because below threshold
|
||||
err = maybeAutoCompactDeletions(ctx, jsonlPath)
|
||||
if err != nil {
|
||||
t.Errorf("auto-compact failed: %v", err)
|
||||
}
|
||||
|
||||
// Read deletions file - should be unchanged
|
||||
afterContent, err := os.ReadFile(deletionsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read deletions file: %v", err)
|
||||
}
|
||||
|
||||
if string(afterContent) != deletionsContent {
|
||||
t.Error("deletions file should not be modified when below threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeJSONLWithDeletions_NoDeletions(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(beadsDir, 0755)
|
||||
|
||||
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
|
||||
jsonlContent := `{"id":"bd-1","title":"Issue 1"}
|
||||
{"id":"bd-2","title":"Issue 2"}
|
||||
{"id":"bd-3","title":"Issue 3"}
|
||||
`
|
||||
os.WriteFile(jsonlPath, []byte(jsonlContent), 0644)
|
||||
|
||||
// No deletions.jsonl file - should return without changes
|
||||
result, err := sanitizeJSONLWithDeletions(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.RemovedCount != 0 {
|
||||
t.Errorf("expected 0 removed, got %d", result.RemovedCount)
|
||||
}
|
||||
|
||||
// Verify JSONL unchanged
|
||||
afterContent, _ := os.ReadFile(jsonlPath)
|
||||
if string(afterContent) != jsonlContent {
|
||||
t.Error("JSONL should not be modified when no deletions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeJSONLWithDeletions_EmptyDeletions(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(beadsDir, 0755)
|
||||
|
||||
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
|
||||
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
||||
|
||||
jsonlContent := `{"id":"bd-1","title":"Issue 1"}
|
||||
{"id":"bd-2","title":"Issue 2"}
|
||||
`
|
||||
os.WriteFile(jsonlPath, []byte(jsonlContent), 0644)
|
||||
os.WriteFile(deletionsPath, []byte(""), 0644)
|
||||
|
||||
result, err := sanitizeJSONLWithDeletions(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.RemovedCount != 0 {
|
||||
t.Errorf("expected 0 removed, got %d", result.RemovedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeJSONLWithDeletions_RemovesDeletedIssues(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(beadsDir, 0755)
|
||||
|
||||
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
|
||||
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
||||
|
||||
// JSONL with 4 issues
|
||||
jsonlContent := `{"id":"bd-1","title":"Issue 1"}
|
||||
{"id":"bd-2","title":"Issue 2"}
|
||||
{"id":"bd-3","title":"Issue 3"}
|
||||
{"id":"bd-4","title":"Issue 4"}
|
||||
`
|
||||
os.WriteFile(jsonlPath, []byte(jsonlContent), 0644)
|
||||
|
||||
// Deletions manifest marks bd-2 and bd-4 as deleted
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
deletionsContent := fmt.Sprintf(`{"id":"bd-2","ts":"%s","by":"user","reason":"cleanup"}
|
||||
{"id":"bd-4","ts":"%s","by":"user","reason":"duplicate"}
|
||||
`, now, now)
|
||||
os.WriteFile(deletionsPath, []byte(deletionsContent), 0644)
|
||||
|
||||
result, err := sanitizeJSONLWithDeletions(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.RemovedCount != 2 {
|
||||
t.Errorf("expected 2 removed, got %d", result.RemovedCount)
|
||||
}
|
||||
if len(result.RemovedIDs) != 2 {
|
||||
t.Errorf("expected 2 RemovedIDs, got %d", len(result.RemovedIDs))
|
||||
}
|
||||
|
||||
// Verify correct IDs were removed
|
||||
removedMap := make(map[string]bool)
|
||||
for _, id := range result.RemovedIDs {
|
||||
removedMap[id] = true
|
||||
}
|
||||
if !removedMap["bd-2"] || !removedMap["bd-4"] {
|
||||
t.Errorf("expected bd-2 and bd-4 to be removed, got %v", result.RemovedIDs)
|
||||
}
|
||||
|
||||
// Verify JSONL now only has bd-1 and bd-3
|
||||
afterContent, _ := os.ReadFile(jsonlPath)
|
||||
afterCount, _ := countIssuesInJSONL(jsonlPath)
|
||||
if afterCount != 2 {
|
||||
t.Errorf("expected 2 issues in JSONL after sanitize, got %d", afterCount)
|
||||
}
|
||||
if !strings.Contains(string(afterContent), `"id":"bd-1"`) {
|
||||
t.Error("JSONL should still contain bd-1")
|
||||
}
|
||||
if !strings.Contains(string(afterContent), `"id":"bd-3"`) {
|
||||
t.Error("JSONL should still contain bd-3")
|
||||
}
|
||||
if strings.Contains(string(afterContent), `"id":"bd-2"`) {
|
||||
t.Error("JSONL should NOT contain deleted bd-2")
|
||||
}
|
||||
if strings.Contains(string(afterContent), `"id":"bd-4"`) {
|
||||
t.Error("JSONL should NOT contain deleted bd-4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeJSONLWithDeletions_NoMatchingDeletions(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(beadsDir, 0755)
|
||||
|
||||
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
|
||||
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
||||
|
||||
// JSONL with issues
|
||||
jsonlContent := `{"id":"bd-1","title":"Issue 1"}
|
||||
{"id":"bd-2","title":"Issue 2"}
|
||||
`
|
||||
os.WriteFile(jsonlPath, []byte(jsonlContent), 0644)
|
||||
|
||||
// Deletions for different IDs
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
deletionsContent := fmt.Sprintf(`{"id":"bd-99","ts":"%s","by":"user"}
|
||||
{"id":"bd-100","ts":"%s","by":"user"}
|
||||
`, now, now)
|
||||
os.WriteFile(deletionsPath, []byte(deletionsContent), 0644)
|
||||
|
||||
result, err := sanitizeJSONLWithDeletions(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.RemovedCount != 0 {
|
||||
t.Errorf("expected 0 removed (no matching IDs), got %d", result.RemovedCount)
|
||||
}
|
||||
|
||||
// Verify JSONL unchanged
|
||||
afterContent, _ := os.ReadFile(jsonlPath)
|
||||
if string(afterContent) != jsonlContent {
|
||||
t.Error("JSONL should not be modified when no matching deletions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeJSONLWithDeletions_PreservesMalformedLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(beadsDir, 0755)
|
||||
|
||||
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
|
||||
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
||||
|
||||
// JSONL with a malformed line
|
||||
jsonlContent := `{"id":"bd-1","title":"Issue 1"}
|
||||
this is not valid json
|
||||
{"id":"bd-2","title":"Issue 2"}
|
||||
`
|
||||
os.WriteFile(jsonlPath, []byte(jsonlContent), 0644)
|
||||
|
||||
// Delete bd-2
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
os.WriteFile(deletionsPath, []byte(fmt.Sprintf(`{"id":"bd-2","ts":"%s","by":"user"}`, now)+"\n"), 0644)
|
||||
|
||||
result, err := sanitizeJSONLWithDeletions(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.RemovedCount != 1 {
|
||||
t.Errorf("expected 1 removed, got %d", result.RemovedCount)
|
||||
}
|
||||
|
||||
// Verify malformed line is preserved (let import handle it)
|
||||
afterContent, _ := os.ReadFile(jsonlPath)
|
||||
if !strings.Contains(string(afterContent), "this is not valid json") {
|
||||
t.Error("malformed line should be preserved")
|
||||
}
|
||||
if !strings.Contains(string(afterContent), `"id":"bd-1"`) {
|
||||
t.Error("bd-1 should be preserved")
|
||||
}
|
||||
if strings.Contains(string(afterContent), `"id":"bd-2"`) {
|
||||
t.Error("bd-2 should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeJSONLWithDeletions_NonexistentJSONL(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(beadsDir, 0755)
|
||||
|
||||
jsonlPath := filepath.Join(beadsDir, "nonexistent.jsonl")
|
||||
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
||||
|
||||
// Create deletions file
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
os.WriteFile(deletionsPath, []byte(fmt.Sprintf(`{"id":"bd-1","ts":"%s","by":"user"}`, now)+"\n"), 0644)
|
||||
|
||||
// Should handle missing JSONL gracefully
|
||||
result, err := sanitizeJSONLWithDeletions(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for missing JSONL: %v", err)
|
||||
}
|
||||
if result.RemovedCount != 0 {
|
||||
t.Errorf("expected 0 removed for missing file, got %d", result.RemovedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeJSONLWithDeletions_PreservesTombstones tests the bd-kzxd fix:
|
||||
// Tombstones should NOT be removed by sanitize, even if their ID is in deletions.jsonl.
|
||||
// Tombstones ARE the proper representation of deletions. Removing them would cause
|
||||
// the importer to re-create tombstones from deletions.jsonl, leading to UNIQUE
|
||||
// constraint errors when the tombstone already exists in the database.
|
||||
func TestSanitizeJSONLWithDeletions_PreservesTombstones(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(beadsDir, 0755)
|
||||
|
||||
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
|
||||
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
|
||||
// JSONL with:
|
||||
// - bd-1: regular issue (should be kept)
|
||||
// - bd-2: tombstone (should be kept even though it's in deletions.jsonl)
|
||||
// - bd-3: regular issue that's in deletions.jsonl (should be removed)
|
||||
jsonlContent := fmt.Sprintf(`{"id":"bd-1","title":"Issue 1","status":"open"}
|
||||
{"id":"bd-2","title":"(deleted)","status":"tombstone","deleted_at":"%s","deleted_by":"user"}
|
||||
{"id":"bd-3","title":"Issue 3","status":"open"}
|
||||
`, now)
|
||||
os.WriteFile(jsonlPath, []byte(jsonlContent), 0644)
|
||||
|
||||
// Deletions manifest marks bd-2 and bd-3 as deleted
|
||||
deletionsContent := fmt.Sprintf(`{"id":"bd-2","ts":"%s","by":"user","reason":"cleanup"}
|
||||
{"id":"bd-3","ts":"%s","by":"user","reason":"duplicate"}
|
||||
`, now, now)
|
||||
os.WriteFile(deletionsPath, []byte(deletionsContent), 0644)
|
||||
|
||||
result, err := sanitizeJSONLWithDeletions(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Only bd-3 should be removed (non-tombstone issue in deletions)
|
||||
// bd-2 should be kept (it's a tombstone)
|
||||
if result.RemovedCount != 1 {
|
||||
t.Errorf("expected 1 removed (only non-tombstone), got %d", result.RemovedCount)
|
||||
}
|
||||
if len(result.RemovedIDs) != 1 || result.RemovedIDs[0] != "bd-3" {
|
||||
t.Errorf("expected only bd-3 to be removed, got %v", result.RemovedIDs)
|
||||
}
|
||||
|
||||
// Verify JSONL content
|
||||
afterContent, _ := os.ReadFile(jsonlPath)
|
||||
afterStr := string(afterContent)
|
||||
|
||||
// bd-1 should still be present (not in deletions)
|
||||
if !strings.Contains(afterStr, `"id":"bd-1"`) {
|
||||
t.Error("JSONL should still contain bd-1")
|
||||
}
|
||||
|
||||
// bd-2 should still be present (tombstone - preserved!)
|
||||
if !strings.Contains(afterStr, `"id":"bd-2"`) {
|
||||
t.Error("JSONL should still contain bd-2 (tombstone should be preserved)")
|
||||
}
|
||||
if !strings.Contains(afterStr, `"status":"tombstone"`) {
|
||||
t.Error("JSONL should contain tombstone status")
|
||||
}
|
||||
|
||||
// bd-3 should be removed (non-tombstone in deletions)
|
||||
if strings.Contains(afterStr, `"id":"bd-3"`) {
|
||||
t.Error("JSONL should NOT contain bd-3 (non-tombstone in deletions)")
|
||||
}
|
||||
|
||||
// Verify we have exactly 2 issues left (bd-1 and bd-2)
|
||||
afterCount, _ := countIssuesInJSONL(jsonlPath)
|
||||
if afterCount != 2 {
|
||||
t.Errorf("expected 2 issues in JSONL after sanitize, got %d", afterCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeJSONLWithDeletions_ProtectsLeftSnapshot tests the bd-3ee1 fix:
|
||||
// Issues that are in the left snapshot (local export before pull) should NOT be
|
||||
// removed by sanitize, even if they have an ID that matches an entry in the
|
||||
// deletions manifest. This prevents newly created issues from being incorrectly
|
||||
// removed when they happen to have an ID that matches a previously deleted issue
|
||||
// (possible with hash-based IDs if content is similar).
|
||||
func TestSanitizeJSONLWithDeletions_ProtectsLeftSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(beadsDir, 0755)
|
||||
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
||||
leftSnapshotPath := filepath.Join(beadsDir, "beads.left.jsonl")
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
|
||||
// JSONL with:
|
||||
// - bd-1: regular issue (should be kept - not in deletions)
|
||||
// - bd-2: regular issue in deletions AND in left snapshot (should be PROTECTED)
|
||||
// - bd-3: regular issue in deletions but NOT in left snapshot (should be removed)
|
||||
jsonlContent := `{"id":"bd-1","title":"Issue 1","status":"open"}
|
||||
{"id":"bd-2","title":"Issue 2","status":"open"}
|
||||
{"id":"bd-3","title":"Issue 3","status":"open"}
|
||||
`
|
||||
os.WriteFile(jsonlPath, []byte(jsonlContent), 0644)
|
||||
|
||||
// Left snapshot contains bd-1 and bd-2 (local work before pull)
|
||||
// bd-2 is the issue we're testing protection for
|
||||
leftSnapshotContent := `{"id":"bd-1","title":"Issue 1","status":"open"}
|
||||
{"id":"bd-2","title":"Issue 2","status":"open"}
|
||||
`
|
||||
os.WriteFile(leftSnapshotPath, []byte(leftSnapshotContent), 0644)
|
||||
|
||||
// Deletions manifest marks bd-2 and bd-3 as deleted
|
||||
// bd-2 is in deletions but should be protected (it's in left snapshot)
|
||||
// bd-3 is in deletions and should be removed (it's NOT in left snapshot)
|
||||
deletionsContent := fmt.Sprintf(`{"id":"bd-2","ts":"%s","by":"user","reason":"old deletion with same ID as new issue"}
|
||||
{"id":"bd-3","ts":"%s","by":"user","reason":"legitimate deletion"}
|
||||
`, now, now)
|
||||
os.WriteFile(deletionsPath, []byte(deletionsContent), 0644)
|
||||
|
||||
result, err := sanitizeJSONLWithDeletions(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// bd-3 should be removed (in deletions, not in left snapshot)
|
||||
if result.RemovedCount != 1 {
|
||||
t.Errorf("expected 1 removed, got %d", result.RemovedCount)
|
||||
}
|
||||
if len(result.RemovedIDs) != 1 || result.RemovedIDs[0] != "bd-3" {
|
||||
t.Errorf("expected only bd-3 to be removed, got %v", result.RemovedIDs)
|
||||
}
|
||||
|
||||
// bd-2 should be protected (in left snapshot)
|
||||
if result.ProtectedCount != 1 {
|
||||
t.Errorf("expected 1 protected, got %d", result.ProtectedCount)
|
||||
}
|
||||
if len(result.ProtectedIDs) != 1 || result.ProtectedIDs[0] != "bd-2" {
|
||||
t.Errorf("expected bd-2 to be protected, got %v", result.ProtectedIDs)
|
||||
}
|
||||
|
||||
// Verify JSONL content
|
||||
afterContent, _ := os.ReadFile(jsonlPath)
|
||||
afterStr := string(afterContent)
|
||||
|
||||
// bd-1 should still be present (not in deletions)
|
||||
if !strings.Contains(afterStr, `"id":"bd-1"`) {
|
||||
t.Error("JSONL should still contain bd-1")
|
||||
}
|
||||
|
||||
// bd-2 should still be present (protected by left snapshot - bd-3ee1 fix!)
|
||||
if !strings.Contains(afterStr, `"id":"bd-2"`) {
|
||||
t.Error("JSONL should still contain bd-2 (protected by left snapshot)")
|
||||
}
|
||||
|
||||
// bd-3 should be removed (in deletions, not protected)
|
||||
if strings.Contains(afterStr, `"id":"bd-3"`) {
|
||||
t.Error("JSONL should NOT contain bd-3 (in deletions and not in left snapshot)")
|
||||
}
|
||||
|
||||
// Verify we have exactly 2 issues left (bd-1 and bd-2)
|
||||
afterCount, _ := countIssuesInJSONL(jsonlPath)
|
||||
if afterCount != 2 {
|
||||
t.Errorf("expected 2 issues in JSONL after sanitize, got %d", afterCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeJSONLWithDeletions_NoLeftSnapshot tests that sanitize still works
|
||||
// correctly when there's no left snapshot (e.g., first sync or snapshot cleanup).
|
||||
func TestSanitizeJSONLWithDeletions_NoLeftSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
os.MkdirAll(beadsDir, 0755)
|
||||
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
||||
// NOTE: No left snapshot file created
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
|
||||
// JSONL with issues
|
||||
jsonlContent := `{"id":"bd-1","title":"Issue 1","status":"open"}
|
||||
{"id":"bd-2","title":"Issue 2","status":"open"}
|
||||
`
|
||||
os.WriteFile(jsonlPath, []byte(jsonlContent), 0644)
|
||||
|
||||
// Deletions manifest marks bd-2 as deleted
|
||||
deletionsContent := fmt.Sprintf(`{"id":"bd-2","ts":"%s","by":"user","reason":"deleted"}
|
||||
`, now)
|
||||
os.WriteFile(deletionsPath, []byte(deletionsContent), 0644)
|
||||
|
||||
result, err := sanitizeJSONLWithDeletions(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Without left snapshot, bd-2 should be removed (no protection available)
|
||||
if result.RemovedCount != 1 {
|
||||
t.Errorf("expected 1 removed, got %d", result.RemovedCount)
|
||||
}
|
||||
if result.ProtectedCount != 0 {
|
||||
t.Errorf("expected 0 protected (no left snapshot), got %d", result.ProtectedCount)
|
||||
}
|
||||
|
||||
// Verify JSONL content
|
||||
afterContent, _ := os.ReadFile(jsonlPath)
|
||||
afterStr := string(afterContent)
|
||||
|
||||
if !strings.Contains(afterStr, `"id":"bd-1"`) {
|
||||
t.Error("JSONL should still contain bd-1")
|
||||
}
|
||||
if strings.Contains(afterStr, `"id":"bd-2"`) {
|
||||
t.Error("JSONL should NOT contain bd-2 (no left snapshot protection)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashBasedStalenessDetection_bd_f2f tests the bd-f2f fix:
|
||||
// When JSONL content differs from stored hash (e.g., remote changed status),
|
||||
// hasJSONLChanged should detect the mismatch even if counts are equal.
|
||||
|
||||
Reference in New Issue
Block a user