From ba2aef429ccea1b0275e815c4d7e5352079f5bb8 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sat, 8 Nov 2025 03:10:12 -0800 Subject: [PATCH] Fix bd-8kde: disable auto-import during delete operations - Skip auto-import in PersistentPreRun for delete command - Prevents resurrection of deleted issues from stale git JSONL - Add TestBulkDeleteNoResurrection to prevent regression - Verified with full test suite Amp-Thread-ID: https://ampcode.com/threads/T-635e5cf4-fa2f-4ce3-9f05-43b48010baae Co-authored-by: Amp --- cmd/bd/delete_test.go | 172 ++++++++++++++++++++++++++++++++++++++++++ cmd/bd/main.go | 3 +- 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/cmd/bd/delete_test.go b/cmd/bd/delete_test.go index d18d9772..42b0ae8b 100644 --- a/cmd/bd/delete_test.go +++ b/cmd/bd/delete_test.go @@ -1,9 +1,17 @@ package main import ( + "bufio" + "bytes" + "context" + "encoding/json" "os" + "os/exec" "path/filepath" "testing" + + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" ) func TestReadIssueIDsFromFile(t *testing.T) { @@ -97,3 +105,167 @@ func TestUniqueStrings(t *testing.T) { } }) } + +func TestBulkDeleteNoResurrection(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + testDB := filepath.Join(beadsDir, "beads.db") + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + + testGitInit(t, tmpDir) + + s := newTestStore(t, testDB) + ctx := context.Background() + + totalIssues := 20 + toDeleteCount := 10 + var toDelete []string + + for i := 1; i <= totalIssues; i++ { + issue := &types.Issue{ + Title: "Issue " + string(rune('A'+i-1)), + Description: "Test issue", + Status: types.StatusOpen, + Priority: 2, + IssueType: "task", + } + if err := s.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("Failed to create issue %d: %v", i, err) + } + if i <= toDeleteCount { + toDelete = append(toDelete, issue.ID) + } + } + + exportToJSONLTest(t, s, jsonlPath) + testGitCommit(t, tmpDir, jsonlPath, "Add issues") + + oldStore := store + oldDbPath := dbPath + oldAutoImportEnabled := autoImportEnabled + defer func() { + store = oldStore + dbPath = oldDbPath + autoImportEnabled = oldAutoImportEnabled + }() + + store = s + dbPath = testDB + autoImportEnabled = true + + result, err := s.DeleteIssues(ctx, toDelete, false, true, false) + if err != nil { + t.Fatalf("DeleteIssues failed: %v", err) + } + + if result.DeletedCount != toDeleteCount { + t.Errorf("Expected %d deletions, got %d", toDeleteCount, result.DeletedCount) + } + + for _, id := range toDelete { + if err := removeIssueFromJSONL(id); err != nil { + t.Fatalf("removeIssueFromJSONL failed for %s: %v", id, err) + } + } + + stats, err := s.GetStatistics(ctx) + if err != nil { + t.Fatalf("GetStatistics failed: %v", err) + } + + expectedRemaining := totalIssues - toDeleteCount + if stats.TotalIssues != expectedRemaining { + t.Errorf("After delete: expected %d issues in DB, got %d", expectedRemaining, stats.TotalIssues) + } + + jsonlIssues := countJSONLIssuesTest(t, jsonlPath) + if jsonlIssues != expectedRemaining { + t.Errorf("After delete: expected %d issues in JSONL, got %d", expectedRemaining, jsonlIssues) + } + + for _, id := range toDelete { + issue, err := s.GetIssue(ctx, id) + if err != nil { + t.Fatalf("GetIssue failed for %s: %v", id, err) + } + if issue != nil { + t.Errorf("Deleted issue %s was resurrected!", id) + } + } +} + +func exportToJSONLTest(t *testing.T, s *sqlite.SQLiteStorage, jsonlPath string) { + t.Helper() + ctx := context.Background() + issues, err := s.SearchIssues(ctx, "", types.IssueFilter{}) + if err != nil { + t.Fatalf("SearchIssues failed: %v", err) + } + + if err := os.MkdirAll(filepath.Dir(jsonlPath), 0755); err != nil { + t.Fatalf("Failed to create JSONL dir: %v", err) + } + + f, err := os.Create(jsonlPath) + if err != nil { + t.Fatalf("Failed to create JSONL: %v", err) + } + defer f.Close() + + enc := json.NewEncoder(f) + for _, iss := range issues { + if err := enc.Encode(iss); err != nil { + t.Fatalf("Failed to encode issue: %v", err) + } + } +} + +func testGitInit(t *testing.T, dir string) { + t.Helper() + testGitCmd(t, dir, "init") + testGitCmd(t, dir, "config", "user.email", "test@example.com") + testGitCmd(t, dir, "config", "user.name", "Test User") +} + +func testGitCommit(t *testing.T, dir, file, msg string) { + t.Helper() + testGitCmd(t, dir, "add", file) + testGitCmd(t, dir, "commit", "-m", msg) +} + +func testGitCmd(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\nOutput: %s", args, err, output) + } +} + +func countJSONLIssuesTest(t *testing.T, jsonlPath string) int { + t.Helper() + data, err := os.ReadFile(jsonlPath) + if err != nil { + if os.IsNotExist(err) { + return 0 + } + t.Fatalf("Failed to read JSONL: %v", err) + } + + count := 0 + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := scanner.Text() + if len(bytes.TrimSpace([]byte(line))) > 0 { + count++ + } + } + if err := scanner.Err(); err != nil { + t.Fatalf("Scanner error: %v", err) + } + return count +} diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 941e7edb..91e28d94 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -415,8 +415,9 @@ var rootCmd = &cobra.Command{ // Auto-import if JSONL is newer than DB (e.g., after git pull) // Skip for import command itself to avoid recursion + // Skip for delete command to prevent resurrection of deleted issues (bd-8kde) // Skip if sync --dry-run to avoid modifying DB in dry-run mode (bd-191) - if cmd.Name() != "import" && autoImportEnabled { + if cmd.Name() != "import" && cmd.Name() != "delete" && autoImportEnabled { // Check if this is sync command with --dry-run flag if cmd.Name() == "sync" { if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {