fix: support delete in --no-db mode (GH#822)
Add CreateTombstone() to MemoryStorage and deleteBatchFallback() to handle deletion when SQLite is not available. This fixes the error "tombstone operation not supported by this storage backend" when using bd delete with --no-db flag. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
ee51298fd5
commit
b161e22144
113
cmd/bd/delete.go
113
cmd/bd/delete.go
@@ -437,8 +437,9 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
|
||||
// Type assert to SQLite storage
|
||||
d, ok := store.(*sqlite.SQLiteStorage)
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "Error: batch delete not supported by this storage backend\n")
|
||||
os.Exit(1)
|
||||
// Fallback for non-SQLite storage (e.g., MemoryStorage in --no-db mode)
|
||||
deleteBatchFallback(issueIDs, force, dryRun, cascade, jsonOutput, hardDelete, reason)
|
||||
return
|
||||
}
|
||||
// Verify all issues exist
|
||||
issues := make(map[string]*types.Issue)
|
||||
@@ -566,6 +567,114 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deleteBatchFallback handles batch deletion for non-SQLite storage (e.g., MemoryStorage in --no-db mode)
|
||||
// It iterates through issues one by one, creating tombstones for each.
|
||||
func deleteBatchFallback(issueIDs []string, force bool, dryRun bool, cascade bool, jsonOutput bool, hardDelete bool, reason string) {
|
||||
ctx := rootCtx
|
||||
|
||||
// Cascade not supported in fallback mode
|
||||
if cascade {
|
||||
fmt.Fprintf(os.Stderr, "Error: --cascade not supported in --no-db mode\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Verify all issues exist first
|
||||
issues := make(map[string]*types.Issue)
|
||||
notFound := []string{}
|
||||
for _, id := range issueIDs {
|
||||
issue, err := store.GetIssue(ctx, id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting issue %s: %v\n", id, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if issue == nil {
|
||||
notFound = append(notFound, id)
|
||||
} else {
|
||||
issues[id] = issue
|
||||
}
|
||||
}
|
||||
if len(notFound) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Error: issues not found: %s\n", strings.Join(notFound, ", "))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Preview mode
|
||||
if dryRun || !force {
|
||||
fmt.Printf("\n%s\n", ui.RenderFail("⚠️ DELETE PREVIEW"))
|
||||
fmt.Printf("\nIssues to delete (%d):\n", len(issueIDs))
|
||||
for _, id := range issueIDs {
|
||||
if issue := issues[id]; issue != nil {
|
||||
fmt.Printf(" %s: %s\n", id, issue.Title)
|
||||
}
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Printf("\n(Dry-run mode - no changes made)\n")
|
||||
} else {
|
||||
fmt.Printf("\n%s\n", ui.RenderWarn("This operation cannot be undone!"))
|
||||
fmt.Printf("To proceed, run: %s\n",
|
||||
ui.RenderWarn("bd delete "+strings.Join(issueIDs, " ")+" --force"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delete each issue
|
||||
deleteActor := getActorWithGit()
|
||||
deletedCount := 0
|
||||
depsRemoved := 0
|
||||
|
||||
for _, issueID := range issueIDs {
|
||||
// Remove dependencies (outgoing)
|
||||
depRecords, err := store.GetDependencyRecords(ctx, issueID)
|
||||
if err == nil {
|
||||
for _, dep := range depRecords {
|
||||
if err := store.RemoveDependency(ctx, dep.IssueID, dep.DependsOnID, deleteActor); err == nil {
|
||||
depsRemoved++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dependencies (inbound)
|
||||
dependents, err := store.GetDependents(ctx, issueID)
|
||||
if err == nil {
|
||||
for _, dep := range dependents {
|
||||
if err := store.RemoveDependency(ctx, dep.ID, issueID, deleteActor); err == nil {
|
||||
depsRemoved++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create tombstone
|
||||
if err := createTombstone(ctx, issueID, deleteActor, reason); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating tombstone for %s: %v\n", issueID, err)
|
||||
continue
|
||||
}
|
||||
deletedCount++
|
||||
}
|
||||
|
||||
// Hard delete: remove from JSONL immediately
|
||||
if hardDelete {
|
||||
for _, id := range issueIDs {
|
||||
_ = removeIssueFromJSONL(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule auto-flush
|
||||
markDirtyAndScheduleFlush()
|
||||
|
||||
// Output results
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"deleted": issueIDs,
|
||||
"deleted_count": deletedCount,
|
||||
"dependencies_removed": depsRemoved,
|
||||
})
|
||||
} else {
|
||||
fmt.Printf("%s Deleted %d issue(s)\n", ui.RenderPass("✓"), deletedCount)
|
||||
fmt.Printf(" Removed %d dependency link(s)\n", depsRemoved)
|
||||
}
|
||||
}
|
||||
|
||||
// showDeletionPreview shows what would be deleted
|
||||
func showDeletionPreview(issueIDs []string, issues map[string]*types.Issue, cascade bool, depError error) {
|
||||
fmt.Printf("\n%s\n", ui.RenderFail("⚠️ DELETE PREVIEW"))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage/memory"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// TestHandleDelete_DryRun verifies that dry-run mode returns what would be deleted
|
||||
@@ -308,11 +309,15 @@ func TestHandleDelete_WithReason(t *testing.T) {
|
||||
t.Fatalf("delete with reason failed: %s", resp.Error)
|
||||
}
|
||||
|
||||
// Verify issue was deleted
|
||||
// Verify issue was converted to tombstone (now that MemoryStorage supports CreateTombstone)
|
||||
ctx := context.Background()
|
||||
issue, _ := store.GetIssue(ctx, issueIDs[0])
|
||||
if issue != nil {
|
||||
t.Error("issue should have been deleted")
|
||||
if issue == nil {
|
||||
t.Error("issue should exist as tombstone")
|
||||
} else if issue.Status != types.StatusTombstone {
|
||||
t.Errorf("issue should be tombstone, got status=%s", issue.Status)
|
||||
} else if issue.DeleteReason != "test deletion with reason" {
|
||||
t.Errorf("expected DeleteReason='test deletion with reason', got '%s'", issue.DeleteReason)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -474,6 +474,41 @@ func (m *MemoryStorage) CloseIssue(ctx context.Context, id string, reason string
|
||||
}, actor)
|
||||
}
|
||||
|
||||
// CreateTombstone converts an existing issue to a tombstone record.
|
||||
// This is a soft-delete that preserves the issue with status="tombstone".
|
||||
func (m *MemoryStorage) CreateTombstone(ctx context.Context, id string, actor string, reason string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
issue, ok := m.issues[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("issue not found: %s", id)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
issue.OriginalType = string(issue.IssueType)
|
||||
issue.Status = types.StatusTombstone
|
||||
issue.DeletedAt = &now
|
||||
issue.DeletedBy = actor
|
||||
issue.DeleteReason = reason
|
||||
issue.UpdatedAt = now
|
||||
|
||||
// Mark as dirty for export
|
||||
m.dirty[id] = true
|
||||
|
||||
// Record tombstone creation event
|
||||
event := &types.Event{
|
||||
IssueID: id,
|
||||
EventType: "deleted",
|
||||
Actor: actor,
|
||||
Comment: &reason,
|
||||
CreatedAt: now,
|
||||
}
|
||||
m.events[id] = append(m.events[id], event)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteIssue permanently deletes an issue and all associated data
|
||||
func (m *MemoryStorage) DeleteIssue(ctx context.Context, id string) error {
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -2,6 +2,7 @@ package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -949,6 +950,68 @@ func TestStatistics_TombstonesExcludedFromTotal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTombstone(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
issueID := issue.ID
|
||||
|
||||
// Create tombstone
|
||||
if err := store.CreateTombstone(ctx, issueID, "test-actor", "test deletion"); err != nil {
|
||||
t.Fatalf("CreateTombstone failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the issue is now a tombstone
|
||||
updated, err := store.GetIssue(ctx, issueID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if updated.Status != types.StatusTombstone {
|
||||
t.Errorf("Expected status=%s, got %s", types.StatusTombstone, updated.Status)
|
||||
}
|
||||
if updated.DeletedAt == nil {
|
||||
t.Error("Expected DeletedAt to be set")
|
||||
}
|
||||
if updated.DeletedBy != "test-actor" {
|
||||
t.Errorf("Expected DeletedBy=test-actor, got %s", updated.DeletedBy)
|
||||
}
|
||||
if updated.DeleteReason != "test deletion" {
|
||||
t.Errorf("Expected DeleteReason='test deletion', got %s", updated.DeleteReason)
|
||||
}
|
||||
if updated.OriginalType != string(types.TypeTask) {
|
||||
t.Errorf("Expected OriginalType=%s, got %s", types.TypeTask, updated.OriginalType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTombstone_NotFound(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Try to create tombstone for non-existent issue
|
||||
err := store.CreateTombstone(ctx, "nonexistent", "test", "reason")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for non-existent issue")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("Expected 'not found' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigOperations(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
Reference in New Issue
Block a user