package main import ( "bufio" "context" "encoding/json" "fmt" "os" "os/exec" "regexp" "strings" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/ui" ) // deleteViaDaemon uses the RPC daemon to delete issues func deleteViaDaemon(issueIDs []string, force, dryRun, cascade bool, jsonOutput bool, reason string) { // NOTE: The daemon's delete handler implements the core deletion logic. // cascade and detailed dependency handling are not yet implemented in the RPC layer. // For now, we pass force=true to the daemon and rely on its simpler deletion logic. deleteArgs := &rpc.DeleteArgs{ IDs: issueIDs, Force: force, DryRun: dryRun, Cascade: cascade, Reason: reason, } resp, err := daemonClient.Delete(deleteArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if !resp.Success { fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) os.Exit(1) } // Parse response var result map[string]interface{} if err := json.Unmarshal(resp.Data, &result); err != nil { fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) os.Exit(1) } if jsonOutput { outputJSON(result) return } // Pretty print for human output if dryRun { fmt.Printf("Dry run - would delete %v issue(s)\n", result["issue_count"]) return } deletedCount := int(result["deleted_count"].(float64)) totalCount := int(result["total_count"].(float64)) if deletedCount > 0 { if deletedCount == 1 { fmt.Printf("%s Deleted %s\n", ui.RenderPass("✓"), issueIDs[0]) } else { fmt.Printf("%s Deleted %d issue(s)\n", ui.RenderPass("✓"), deletedCount) } } if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 { fmt.Printf("\n%s Warnings:\n", ui.RenderWarn("⚠")) for _, e := range errors { fmt.Printf(" %s\n", e) } if deletedCount < totalCount { os.Exit(1) } } } var deleteCmd = &cobra.Command{ Use: "delete [issue-id...]", GroupID: "issues", Short: "Delete one or more issues and clean up references", Long: `Delete one or more issues and clean up all references to them. This command will: 1. Remove all dependency links (any type, both directions) involving the issues 2. Update text references to "[deleted:ID]" in directly connected issues 3. Delete the issues from the database (creates tombstones by default) This is a destructive operation that cannot be undone. Use with caution. BATCH DELETION: Delete multiple issues at once: bd delete bd-1 bd-2 bd-3 --force Delete from file (one ID per line): bd delete --from-file deletions.txt --force Preview before deleting: bd delete --from-file deletions.txt --dry-run DEPENDENCY HANDLING: Default: Fails if any issue has dependents not in deletion set bd delete bd-1 bd-2 Cascade: Recursively delete all dependents bd delete bd-1 --cascade --force Force: Delete and orphan dependents bd delete bd-1 --force PERMANENT DELETION: Use --hard to permanently delete (bypass tombstones): bd delete bd-1 bd-2 --hard --force WARNING: --hard bypasses sync safety. Use only when you are certain the issues will not resurrect from remote branches.`, Args: cobra.MinimumNArgs(0), Run: func(cmd *cobra.Command, args []string) { CheckReadonly("delete") fromFile, _ := cmd.Flags().GetString("from-file") force, _ := cmd.Flags().GetBool("force") dryRun, _ := cmd.Flags().GetBool("dry-run") cascade, _ := cmd.Flags().GetBool("cascade") hardDelete, _ := cmd.Flags().GetBool("hard") // Use global jsonOutput set by PersistentPreRun // Collect issue IDs from args and/or file issueIDs := make([]string, 0, len(args)) issueIDs = append(issueIDs, args...) if fromFile != "" { fileIDs, err := readIssueIDsFromFile(fromFile) if err != nil { fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) os.Exit(1) } issueIDs = append(issueIDs, fileIDs...) } if len(issueIDs) == 0 { fmt.Fprintf(os.Stderr, "Error: no issue IDs provided\n") _ = cmd.Usage() os.Exit(1) } // Remove duplicates issueIDs = uniqueStrings(issueIDs) // Use daemon if available, otherwise use direct mode if daemonClient != nil { deleteViaDaemon(issueIDs, force, dryRun, cascade, jsonOutput, "delete") return } // Direct mode - ensure store is available if store == nil { if err := ensureStoreActive(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } // Handle batch deletion in direct mode if len(issueIDs) > 1 { deleteBatch(cmd, issueIDs, force, dryRun, cascade, jsonOutput, hardDelete, "batch delete") return } // Single issue deletion (legacy behavior) issueID := issueIDs[0] ctx := rootCtx // Get the issue to be deleted issue, err := store.GetIssue(ctx, issueID) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if issue == nil { fmt.Fprintf(os.Stderr, "Error: issue %s not found\n", issueID) os.Exit(1) } // Find all connected issues (dependencies in both directions) connectedIssues := make(map[string]*types.Issue) // Get dependencies (issues this one depends on) deps, err := store.GetDependencies(ctx, issueID) if err != nil { fmt.Fprintf(os.Stderr, "Error getting dependencies: %v\n", err) os.Exit(1) } for _, dep := range deps { connectedIssues[dep.ID] = dep } // Get dependents (issues that depend on this one) dependents, err := store.GetDependents(ctx, issueID) if err != nil { fmt.Fprintf(os.Stderr, "Error getting dependents: %v\n", err) os.Exit(1) } for _, dependent := range dependents { connectedIssues[dependent.ID] = dependent } // Get dependency records (outgoing) to count how many we'll remove depRecords, err := store.GetDependencyRecords(ctx, issueID) if err != nil { fmt.Fprintf(os.Stderr, "Error getting dependency records: %v\n", err) os.Exit(1) } // Build the regex pattern for matching issue IDs (handles hyphenated IDs properly) // Pattern: (^|non-word-char)(issueID)($|non-word-char) where word-char includes hyphen idPattern := `(^|[^A-Za-z0-9_-])(` + regexp.QuoteMeta(issueID) + `)($|[^A-Za-z0-9_-])` re := regexp.MustCompile(idPattern) replacementText := `$1[deleted:` + issueID + `]$3` // Preview mode if !force { fmt.Printf("\n%s\n", ui.RenderFail("⚠️ DELETE PREVIEW")) fmt.Printf("\nIssue to delete:\n") fmt.Printf(" %s: %s\n", issueID, issue.Title) totalDeps := len(depRecords) + len(dependents) if totalDeps > 0 { fmt.Printf("\nDependency links to remove: %d\n", totalDeps) for _, dep := range depRecords { fmt.Printf(" %s → %s (%s)\n", dep.IssueID, dep.DependsOnID, dep.Type) } for _, dep := range dependents { fmt.Printf(" %s → %s (inbound)\n", dep.ID, issueID) } } if len(connectedIssues) > 0 { fmt.Printf("\nConnected issues where text references will be updated:\n") issuesWithRefs := 0 for id, connIssue := range connectedIssues { // Check if there are actually text references using the fixed regex hasRefs := re.MatchString(connIssue.Description) || (connIssue.Notes != "" && re.MatchString(connIssue.Notes)) || (connIssue.Design != "" && re.MatchString(connIssue.Design)) || (connIssue.AcceptanceCriteria != "" && re.MatchString(connIssue.AcceptanceCriteria)) if hasRefs { fmt.Printf(" %s: %s\n", id, connIssue.Title) issuesWithRefs++ } } if issuesWithRefs == 0 { fmt.Printf(" (none have text references)\n") } } fmt.Printf("\n%s\n", ui.RenderWarn("This operation cannot be undone!")) fmt.Printf("To proceed, run: %s\n\n", ui.RenderWarn("bd delete "+issueID+" --force")) return } // Actually delete deleteActor := getActorWithGit() // 1. Update text references in connected issues (all text fields) updatedIssueCount := 0 for id, connIssue := range connectedIssues { updates := make(map[string]interface{}) // Replace in description if re.MatchString(connIssue.Description) { newDesc := re.ReplaceAllString(connIssue.Description, replacementText) updates["description"] = newDesc } // Replace in notes if connIssue.Notes != "" && re.MatchString(connIssue.Notes) { newNotes := re.ReplaceAllString(connIssue.Notes, replacementText) updates["notes"] = newNotes } // Replace in design if connIssue.Design != "" && re.MatchString(connIssue.Design) { newDesign := re.ReplaceAllString(connIssue.Design, replacementText) updates["design"] = newDesign } // Replace in acceptance_criteria if connIssue.AcceptanceCriteria != "" && re.MatchString(connIssue.AcceptanceCriteria) { newAC := re.ReplaceAllString(connIssue.AcceptanceCriteria, replacementText) updates["acceptance_criteria"] = newAC } if len(updates) > 0 { if err := store.UpdateIssue(ctx, id, updates, actor); err != nil { fmt.Fprintf(os.Stderr, "Warning: Failed to update references in %s: %v\n", id, err) } else { updatedIssueCount++ } } } // 2. Remove all dependency links (outgoing) outgoingRemoved := 0 for _, dep := range depRecords { if err := store.RemoveDependency(ctx, dep.IssueID, dep.DependsOnID, actor); err != nil { fmt.Fprintf(os.Stderr, "Warning: Failed to remove dependency %s → %s: %v\n", dep.IssueID, dep.DependsOnID, err) } else { outgoingRemoved++ } } // 3. Remove inbound dependency links (issues that depend on this one) inboundRemoved := 0 for _, dep := range dependents { if err := store.RemoveDependency(ctx, dep.ID, issueID, actor); err != nil { fmt.Fprintf(os.Stderr, "Warning: Failed to remove dependency %s → %s: %v\n", dep.ID, issueID, err) } else { inboundRemoved++ } } // 4. Create tombstone (instead of deleting from database) // Phase 1 dual-write: still writes to deletions.jsonl (step 0), now also creates tombstone if err := createTombstone(ctx, issueID, deleteActor, "manual delete"); err != nil { fmt.Fprintf(os.Stderr, "Error creating tombstone: %v\n", err) os.Exit(1) } // Note: No longer call removeIssueFromJSONL - tombstone will be exported to JSONL // Schedule auto-flush to update neighbors markDirtyAndScheduleFlush() totalDepsRemoved := outgoingRemoved + inboundRemoved if jsonOutput { outputJSON(map[string]interface{}{ "deleted": issueID, "dependencies_removed": totalDepsRemoved, "references_updated": updatedIssueCount, }) } else { fmt.Printf("%s Deleted %s\n", ui.RenderPass("✓"), issueID) fmt.Printf(" Removed %d dependency link(s)\n", totalDepsRemoved) fmt.Printf(" Updated text references in %d issue(s)\n", updatedIssueCount) } }, } // createTombstone converts an issue to a tombstone record // Note: This is a direct database operation since Storage interface doesn't have CreateTombstone func createTombstone(ctx context.Context, issueID string, actor string, reason string) error { // We need to access the SQLite storage directly // Check if store is SQLite storage type tombstoner interface { CreateTombstone(ctx context.Context, id string, actor string, reason string) error } if t, ok := store.(tombstoner); ok { return t.CreateTombstone(ctx, issueID, actor, reason) } return fmt.Errorf("tombstone operation not supported by this storage backend") } // deleteIssue removes an issue from the database // Note: This is a direct database operation since Storage interface doesn't have Delete func deleteIssue(ctx context.Context, issueID string) error { // We need to access the SQLite storage directly // Check if store is SQLite storage type deleter interface { DeleteIssue(ctx context.Context, id string) error } if d, ok := store.(deleter); ok { return d.DeleteIssue(ctx, issueID) } return fmt.Errorf("delete operation not supported by this storage backend") } // removeIssueFromJSONL removes a deleted issue from the JSONL file // Auto-flush cannot see deletions because the dirty_issues row is deleted with the issue func removeIssueFromJSONL(issueID string) error { path := findJSONLPath() if path == "" { return nil // No JSONL file yet } // Read all issues except the deleted one // #nosec G304 - controlled path from config f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return nil // No file, nothing to clean } return fmt.Errorf("failed to open JSONL: %w", err) } var issues []*types.Issue scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() if strings.TrimSpace(line) == "" { continue } var iss types.Issue if err := json.Unmarshal([]byte(line), &iss); err != nil { // Skip malformed lines continue } if iss.ID != issueID { issues = append(issues, &iss) } } if err := scanner.Err(); err != nil { _ = f.Close() return fmt.Errorf("failed to read JSONL: %w", err) } if err := f.Close(); err != nil { return fmt.Errorf("failed to close JSONL: %w", err) } // Write to temp file atomically temp := fmt.Sprintf("%s.tmp.%d", path, os.Getpid()) // #nosec G304 - controlled path from config out, err := os.OpenFile(temp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { return fmt.Errorf("failed to create temp file: %w", err) } enc := json.NewEncoder(out) for _, iss := range issues { if err := enc.Encode(iss); err != nil { _ = out.Close() _ = os.Remove(temp) return fmt.Errorf("failed to write issue: %w", err) } } if err := out.Close(); err != nil { _ = os.Remove(temp) return fmt.Errorf("failed to close temp file: %w", err) } // Atomic rename if err := os.Rename(temp, path); err != nil { _ = os.Remove(temp) return fmt.Errorf("failed to rename temp file: %w", err) } return nil } // deleteBatch handles deletion of multiple issues //nolint:unparam // cmd parameter required for potential future use func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, cascade bool, jsonOutput bool, hardDelete bool, reason string) { // Ensure we have a direct store if store == nil { if err := ensureStoreActive(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } ctx := rootCtx // 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) } // Verify all issues exist issues := make(map[string]*types.Issue) notFound := []string{} for _, id := range issueIDs { issue, err := d.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) } // Dry-run or preview mode if dryRun || !force { result, err := d.DeleteIssues(ctx, issueIDs, cascade, false, true) if err != nil { // Try to show preview even if there are dependency issues showDeletionPreview(issueIDs, issues, cascade, err) os.Exit(1) } showDeletionPreview(issueIDs, issues, cascade, nil) fmt.Printf("\nWould delete: %d issues\n", result.DeletedCount) fmt.Printf("Would remove: %d dependencies, %d labels, %d events\n", result.DependenciesCount, result.LabelsCount, result.EventsCount) if len(result.OrphanedIssues) > 0 { fmt.Printf("Would orphan: %d issues\n", len(result.OrphanedIssues)) } 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!")) if cascade { fmt.Printf("To proceed with cascade deletion, run: %s\n", ui.RenderWarn("bd delete "+strings.Join(issueIDs, " ")+" --cascade --force")) } else { fmt.Printf("To proceed, run: %s\n", ui.RenderWarn("bd delete "+strings.Join(issueIDs, " ")+" --force")) } } return } // Pre-collect connected issues before deletion (so we can update their text references) connectedIssues := make(map[string]*types.Issue) idSet := make(map[string]bool) for _, id := range issueIDs { idSet[id] = true } for _, id := range issueIDs { // Get dependencies (issues this one depends on) deps, err := store.GetDependencies(ctx, id) if err == nil { for _, dep := range deps { if !idSet[dep.ID] { connectedIssues[dep.ID] = dep } } } // Get dependents (issues that depend on this one) dependents, err := store.GetDependents(ctx, id) if err == nil { for _, dep := range dependents { if !idSet[dep.ID] { connectedIssues[dep.ID] = dep } } } } // Actually delete (creates tombstones) result, err := d.DeleteIssues(ctx, issueIDs, cascade, force, false) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } // Hard delete: immediately prune tombstones from JSONL (bd-4q8) // Note: We keep tombstones in DB to prevent resurrection during sync. // The tombstones will be exported and synced to remote, blocking resurrection. // Use 'bd cleanup --hard' after syncing to fully purge old tombstones. if hardDelete { if !jsonOutput { fmt.Println(ui.RenderWarn("⚠️ HARD DELETE MODE: Pruning tombstones from JSONL")) fmt.Println(" Note: Tombstones kept in DB to prevent resurrection. Run 'bd sync' then 'bd cleanup --hard' to fully purge.") } // Prune tombstones from JSONL using negative TTL (immediate expiration) if pruneResult, err := pruneExpiredTombstones(-1); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to prune tombstones from JSONL: %v\n", err) } else if pruneResult != nil && pruneResult.PrunedCount > 0 && !jsonOutput { fmt.Printf(" Pruned %d tombstone(s) from JSONL\n", pruneResult.PrunedCount) } } // Update text references in connected issues (using pre-collected issues) updatedCount := updateTextReferencesInIssues(ctx, issueIDs, connectedIssues) // Note: No longer remove from JSONL - tombstones will be exported to JSONL (bd-3b4) // Schedule auto-flush markDirtyAndScheduleFlush() // Output results if jsonOutput { outputJSON(map[string]interface{}{ "deleted": issueIDs, "deleted_count": result.DeletedCount, "dependencies_removed": result.DependenciesCount, "labels_removed": result.LabelsCount, "events_removed": result.EventsCount, "references_updated": updatedCount, "orphaned_issues": result.OrphanedIssues, }) } else { fmt.Printf("%s Deleted %d issue(s)\n", ui.RenderPass("✓"), result.DeletedCount) fmt.Printf(" Removed %d dependency link(s)\n", result.DependenciesCount) fmt.Printf(" Removed %d label(s)\n", result.LabelsCount) fmt.Printf(" Removed %d event(s)\n", result.EventsCount) fmt.Printf(" Updated text references in %d issue(s)\n", updatedCount) if len(result.OrphanedIssues) > 0 { fmt.Printf(" %s Orphaned %d issue(s): %s\n", ui.RenderWarn("⚠"), len(result.OrphanedIssues), strings.Join(result.OrphanedIssues, ", ")) } } } // 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")) 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 cascade { fmt.Printf("\n%s Cascade mode enabled - will also delete all dependent issues\n", ui.RenderWarn("⚠")) } if depError != nil { fmt.Printf("\n%s\n", ui.RenderFail(depError.Error())) } } // updateTextReferencesInIssues updates text references to deleted issues in pre-collected connected issues func updateTextReferencesInIssues(ctx context.Context, deletedIDs []string, connectedIssues map[string]*types.Issue) int { updatedCount := 0 // For each deleted issue, update references in all connected issues for _, id := range deletedIDs { // Build regex pattern idPattern := `(^|[^A-Za-z0-9_-])(` + regexp.QuoteMeta(id) + `)($|[^A-Za-z0-9_-])` re := regexp.MustCompile(idPattern) replacementText := `$1[deleted:` + id + `]$3` for connID, connIssue := range connectedIssues { updates := make(map[string]interface{}) if re.MatchString(connIssue.Description) { updates["description"] = re.ReplaceAllString(connIssue.Description, replacementText) } if connIssue.Notes != "" && re.MatchString(connIssue.Notes) { updates["notes"] = re.ReplaceAllString(connIssue.Notes, replacementText) } if connIssue.Design != "" && re.MatchString(connIssue.Design) { updates["design"] = re.ReplaceAllString(connIssue.Design, replacementText) } if connIssue.AcceptanceCriteria != "" && re.MatchString(connIssue.AcceptanceCriteria) { updates["acceptance_criteria"] = re.ReplaceAllString(connIssue.AcceptanceCriteria, replacementText) } if len(updates) > 0 { if err := store.UpdateIssue(ctx, connID, updates, actor); err == nil { updatedCount++ // Update the in-memory issue to avoid double-replacing if desc, ok := updates["description"].(string); ok { connIssue.Description = desc } if notes, ok := updates["notes"].(string); ok { connIssue.Notes = notes } if design, ok := updates["design"].(string); ok { connIssue.Design = design } if ac, ok := updates["acceptance_criteria"].(string); ok { connIssue.AcceptanceCriteria = ac } } } } } return updatedCount } // readIssueIDsFromFile reads issue IDs from a file (one per line) func readIssueIDsFromFile(filename string) ([]string, error) { // #nosec G304 - user-provided file path is intentional f, err := os.Open(filename) if err != nil { return nil, err } defer func() { _ = f.Close() }() var ids []string scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Skip empty lines and comments if line == "" || strings.HasPrefix(line, "#") { continue } ids = append(ids, line) } if err := scanner.Err(); err != nil { return nil, err } return ids, nil } // uniqueStrings removes duplicates from a slice of strings func uniqueStrings(slice []string) []string { seen := make(map[string]bool) result := make([]string, 0, len(slice)) for _, s := range slice { if !seen[s] { seen[s] = true result = append(result, s) } } return result } // getActorWithGit returns the actor for audit trail with git config fallback. // Priority: global actor var (from --actor flag or BD_ACTOR env) > git config user.name > $USER > "unknown" func getActorWithGit() string { // If actor is already set (from flag or env), use it if actor != "" && actor != "unknown" { return actor } // Try git config user.name cmd := exec.Command("git", "config", "user.name") if output, err := cmd.Output(); err == nil { if gitUser := strings.TrimSpace(string(output)); gitUser != "" { return gitUser } } // Fall back to USER env if user := os.Getenv("USER"); user != "" { return user } return "unknown" } func init() { deleteCmd.Flags().BoolP("force", "f", false, "Actually delete (without this flag, shows preview)") deleteCmd.Flags().String("from-file", "", "Read issue IDs from file (one per line)") deleteCmd.Flags().Bool("dry-run", false, "Preview what would be deleted without making changes") deleteCmd.Flags().Bool("cascade", false, "Recursively delete all dependent issues") deleteCmd.Flags().Bool("hard", false, "Permanently delete (skip tombstone, cannot be recovered via sync)") rootCmd.AddCommand(deleteCmd) }