Fix --json flag shadowing issue causing test failures

Fixed TestHashIDs_IdenticalContentDedup test failure by removing duplicate
--json flag definitions that were shadowing the global persistent flag.

Root cause: Commands had both a persistent --json flag (main.go) and local
--json flags (in individual command files). The local flags shadowed the
persistent flag, preventing jsonOutput variable from being set correctly.

Changes:
- Removed 31 duplicate --json flag definitions from 15 command files
- All commands now use the single persistent --json flag from main.go
- Commands now correctly output JSON when --json flag is specified

Test results:
- TestHashIDs_IdenticalContentDedup: Now passes (was failing)
- TestHashIDs_MultiCloneConverge: Passes without JSON parsing warnings
- All other tests: Pass with no regressions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-02 18:52:44 -08:00
parent edf1f71fa7
commit e5f1e4b971
15 changed files with 7 additions and 588 deletions

View File

@@ -1,5 +1,4 @@
package main
import (
"bufio"
"context"
@@ -8,44 +7,32 @@ import (
"os"
"regexp"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
var deleteCmd = &cobra.Command{
Use: "delete <issue-id> [issue-id...]",
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
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`,
Args: cobra.MinimumNArgs(0),
@@ -55,11 +42,9 @@ Force: Delete and orphan dependents
dryRun, _ := cmd.Flags().GetBool("dry-run")
cascade, _ := cmd.Flags().GetBool("cascade")
// 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 {
@@ -68,25 +53,20 @@ Force: Delete and orphan dependents
}
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)
// Handle batch deletion
if len(issueIDs) > 1 {
deleteBatch(cmd, issueIDs, force, dryRun, cascade, jsonOutput)
return
}
// Single issue deletion (legacy behavior)
issueID := issueIDs[0]
// Ensure we have a direct store when daemon lacks delete support
if daemonClient != nil {
if err := ensureDirectMode("daemon does not support delete command"); err != nil {
@@ -99,9 +79,7 @@ Force: Delete and orphan dependents
os.Exit(1)
}
}
ctx := context.Background()
// Get the issue to be deleted
issue, err := store.GetIssue(ctx, issueID)
if err != nil {
@@ -112,10 +90,8 @@ Force: Delete and orphan dependents
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 {
@@ -125,7 +101,6 @@ Force: Delete and orphan dependents
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 {
@@ -135,29 +110,24 @@ Force: Delete and orphan dependents
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 {
red := color.New(color.FgRed).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s\n", red("⚠️ 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)
@@ -168,7 +138,6 @@ Force: Delete and orphan 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
@@ -178,7 +147,6 @@ Force: Delete and orphan dependents
(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++
@@ -188,43 +156,35 @@ Force: Delete and orphan dependents
fmt.Printf(" (none have text references)\n")
}
}
fmt.Printf("\n%s\n", yellow("This operation cannot be undone!"))
fmt.Printf("To proceed, run: %s\n\n", yellow("bd delete "+issueID+" --force"))
return
}
// Actually delete
// 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)
@@ -233,7 +193,6 @@ Force: Delete and orphan dependents
}
}
}
// 2. Remove all dependency links (outgoing)
outgoingRemoved := 0
for _, dep := range depRecords {
@@ -244,7 +203,6 @@ Force: Delete and orphan dependents
outgoingRemoved++
}
}
// 3. Remove inbound dependency links (issues that depend on this one)
inboundRemoved := 0
for _, dep := range dependents {
@@ -255,21 +213,17 @@ Force: Delete and orphan dependents
inboundRemoved++
}
}
// 4. Delete the issue itself from database
if err := deleteIssue(ctx, issueID); err != nil {
fmt.Fprintf(os.Stderr, "Error deleting issue: %v\n", err)
os.Exit(1)
}
// 5. Remove from JSONL (auto-flush can't see deletions)
if err := removeIssueFromJSONL(issueID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to remove from JSONL: %v\n", err)
}
// Schedule auto-flush to update neighbors
markDirtyAndScheduleFlush()
totalDepsRemoved := outgoingRemoved + inboundRemoved
if jsonOutput {
outputJSON(map[string]interface{}{
@@ -285,7 +239,6 @@ Force: Delete and orphan dependents
}
},
}
// 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 {
@@ -294,14 +247,11 @@ func deleteIssue(ctx context.Context, issueID string) error {
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 {
@@ -309,7 +259,6 @@ func removeIssueFromJSONL(issueID string) error {
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)
@@ -319,7 +268,6 @@ func removeIssueFromJSONL(issueID string) error {
}
return fmt.Errorf("failed to open JSONL: %w", err)
}
var issues []*types.Issue
scanner := bufio.NewScanner(f)
for scanner.Scan() {
@@ -340,11 +288,9 @@ func removeIssueFromJSONL(issueID string) error {
_ = 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
@@ -352,7 +298,6 @@ func removeIssueFromJSONL(issueID string) error {
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 {
@@ -361,21 +306,17 @@ func removeIssueFromJSONL(issueID string) error {
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) {
@@ -391,16 +332,13 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
os.Exit(1)
}
}
ctx := context.Background()
// 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{}
@@ -416,12 +354,10 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
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)
@@ -430,7 +366,6 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
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",
@@ -438,7 +373,6 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
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 {
@@ -454,14 +388,12 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
}
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)
@@ -472,7 +404,6 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
}
}
}
// Get dependents (issues that depend on this one)
dependents, err := store.GetDependents(ctx, id)
if err == nil {
@@ -483,27 +414,22 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
}
}
}
// Actually delete
result, err := d.DeleteIssues(ctx, issueIDs, cascade, force, false)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Update text references in connected issues (using pre-collected issues)
updatedCount := updateTextReferencesInIssues(ctx, issueIDs, connectedIssues)
// Remove from JSONL
for _, id := range issueIDs {
if err := removeIssueFromJSONL(id); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to remove %s from JSONL: %v\n", id, err)
}
}
// Schedule auto-flush
markDirtyAndScheduleFlush()
// Output results
if jsonOutput {
outputJSON(map[string]interface{}{
@@ -529,12 +455,10 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
}
}
}
// showDeletionPreview shows what would be deleted
func showDeletionPreview(issueIDs []string, issues map[string]*types.Issue, cascade bool, depError error) {
red := color.New(color.FgRed).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s\n", red("⚠️ DELETE PREVIEW"))
fmt.Printf("\nIssues to delete (%d):\n", len(issueIDs))
for _, id := range issueIDs {
@@ -542,30 +466,24 @@ func showDeletionPreview(issueIDs []string, issues map[string]*types.Issue, casc
fmt.Printf(" %s: %s\n", id, issue.Title)
}
}
if cascade {
fmt.Printf("\n%s Cascade mode enabled - will also delete all dependent issues\n", yellow("⚠"))
}
if depError != nil {
fmt.Printf("\n%s\n", red(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)
}
@@ -578,7 +496,6 @@ func updateTextReferencesInIssues(ctx context.Context, deletedIDs []string, conn
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++
@@ -599,10 +516,8 @@ func updateTextReferencesInIssues(ctx context.Context, deletedIDs []string, conn
}
}
}
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
@@ -611,7 +526,6 @@ func readIssueIDsFromFile(filename string) ([]string, error) {
return nil, err
}
defer func() { _ = f.Close() }()
var ids []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
@@ -622,14 +536,11 @@ func readIssueIDsFromFile(filename string) ([]string, error) {
}
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)
@@ -642,12 +553,10 @@ func uniqueStrings(slice []string) []string {
}
return result
}
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("json", false, "Output JSON format")
rootCmd.AddCommand(deleteCmd)
}