feat: bd repair - add comments/events orphan detection and --json flag

- Check comments table for orphaned issue_id references
- Check events table for orphaned issue_id references
- Delete orphaned comments/events in repair transaction
- Add --json flag for machine-readable output with:
  - orphan_counts by type (dependencies, labels, comments, events)
  - orphan_details with full reference info
  - status (success, no_orphans, dry_run, error)
  - backup_path when applicable

(bd-2wm2, bd-ie7j)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-29 12:58:00 -08:00
parent 75e2afdae5
commit cd942f136d

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -30,31 +31,53 @@ This command opens SQLite directly (bypassing invariant checks) and cleans:
- Orphaned dependencies (issue_id not in issues) - Orphaned dependencies (issue_id not in issues)
- Orphaned dependencies (depends_on_id not in issues, excluding external refs) - Orphaned dependencies (depends_on_id not in issues, excluding external refs)
- Orphaned labels (issue_id not in issues) - Orphaned labels (issue_id not in issues)
- Orphaned comments (issue_id not in issues)
- Orphaned events (issue_id not in issues)
After repair, normal bd commands should work again. After repair, normal bd commands should work again.
Examples: Examples:
bd repair # Repair database in current directory bd repair # Repair database in current directory
bd repair --dry-run # Show what would be cleaned without making changes bd repair --dry-run # Show what would be cleaned without making changes
bd repair --path /other/repo # Repair database in another location`, bd repair --path /other/repo # Repair database in another location
bd repair --json # Output results as JSON`,
Run: runRepair, Run: runRepair,
} }
var ( var (
repairDryRun bool repairDryRun bool
repairPath string repairPath string
repairJSON bool
) )
func init() { func init() {
repairCmd.Flags().BoolVar(&repairDryRun, "dry-run", false, "Show what would be cleaned without making changes") repairCmd.Flags().BoolVar(&repairDryRun, "dry-run", false, "Show what would be cleaned without making changes")
repairCmd.Flags().StringVar(&repairPath, "path", ".", "Path to repository with .beads directory") repairCmd.Flags().StringVar(&repairPath, "path", ".", "Path to repository with .beads directory")
repairCmd.Flags().BoolVar(&repairJSON, "json", false, "Output results as JSON")
rootCmd.AddCommand(repairCmd) rootCmd.AddCommand(repairCmd)
} }
// outputJSONAndExit outputs the repair result as JSON and exits
func outputJSONAndExit(result repairResult, exitCode int) {
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, `{"error": "failed to marshal JSON: %v"}`, err)
os.Exit(1)
}
fmt.Println(string(data))
os.Exit(exitCode)
}
func runRepair(cmd *cobra.Command, args []string) { func runRepair(cmd *cobra.Command, args []string) {
// Find .beads directory // Find .beads directory
beadsDir := filepath.Join(repairPath, ".beads") beadsDir := filepath.Join(repairPath, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) { if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
if repairJSON {
outputJSONAndExit(repairResult{
Status: "error",
Error: fmt.Sprintf(".beads directory not found at %s", beadsDir),
}, 1)
}
fmt.Fprintf(os.Stderr, "Error: .beads directory not found at %s\n", beadsDir) fmt.Fprintf(os.Stderr, "Error: .beads directory not found at %s\n", beadsDir)
os.Exit(1) os.Exit(1)
} }
@@ -62,19 +85,34 @@ func runRepair(cmd *cobra.Command, args []string) {
// Find database file // Find database file
dbPath := filepath.Join(beadsDir, "beads.db") dbPath := filepath.Join(beadsDir, "beads.db")
if _, err := os.Stat(dbPath); os.IsNotExist(err) { if _, err := os.Stat(dbPath); os.IsNotExist(err) {
if repairJSON {
outputJSONAndExit(repairResult{
Status: "error",
Error: fmt.Sprintf("database not found at %s", dbPath),
}, 1)
}
fmt.Fprintf(os.Stderr, "Error: database not found at %s\n", dbPath) fmt.Fprintf(os.Stderr, "Error: database not found at %s\n", dbPath)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Repairing database: %s\n", dbPath) if !repairJSON {
if repairDryRun { fmt.Printf("Repairing database: %s\n", dbPath)
fmt.Println("[DRY-RUN] No changes will be made") if repairDryRun {
fmt.Println("[DRY-RUN] No changes will be made")
}
fmt.Println()
} }
fmt.Println()
// Open database directly, bypassing beads storage layer // Open database directly, bypassing beads storage layer
db, err := openRepairDB(dbPath) db, err := openRepairDB(dbPath)
if err != nil { if err != nil {
if repairJSON {
outputJSONAndExit(repairResult{
DatabasePath: dbPath,
Status: "error",
Error: fmt.Sprintf("opening database: %v", err),
}, 1)
}
fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err) fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -107,53 +145,146 @@ func runRepair(cmd *cobra.Command, args []string) {
} }
stats.orphanedLabels = len(orphanedLabels) stats.orphanedLabels = len(orphanedLabels)
// Print findings // 4. Find and clean orphaned comments
orphanedCommentsList, err := findOrphanedComments(db)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking orphaned comments: %v\n", err)
os.Exit(1)
}
stats.orphanedComments = len(orphanedCommentsList)
// 5. Find and clean orphaned events
orphanedEventsList, err := findOrphanedEvents(db)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking orphaned events: %v\n", err)
os.Exit(1)
}
stats.orphanedEvents = len(orphanedEventsList)
// Build JSON result structure (used for both JSON output and tracking)
jsonResult := repairResult{
DatabasePath: dbPath,
DryRun: repairDryRun,
OrphanCounts: repairOrphanCounts{
DependenciesIssueID: stats.orphanedDepsIssueID,
DependenciesDependsOn: stats.orphanedDepsDependsOn,
Labels: stats.orphanedLabels,
Comments: stats.orphanedComments,
Events: stats.orphanedEvents,
Total: stats.total(),
},
}
// Build orphan details
for _, dep := range orphanedIssueID {
jsonResult.OrphanDetails.DependenciesIssueID = append(jsonResult.OrphanDetails.DependenciesIssueID,
repairDepDetail{IssueID: dep.issueID, DependsOnID: dep.dependsOnID})
}
for _, dep := range orphanedDependsOn {
jsonResult.OrphanDetails.DependenciesDependsOn = append(jsonResult.OrphanDetails.DependenciesDependsOn,
repairDepDetail{IssueID: dep.issueID, DependsOnID: dep.dependsOnID})
}
for _, label := range orphanedLabels {
jsonResult.OrphanDetails.Labels = append(jsonResult.OrphanDetails.Labels,
repairLabelDetail{IssueID: label.issueID, Label: label.label})
}
for _, comment := range orphanedCommentsList {
jsonResult.OrphanDetails.Comments = append(jsonResult.OrphanDetails.Comments,
repairCommentDetail{ID: comment.id, IssueID: comment.issueID, Author: comment.author})
}
for _, event := range orphanedEventsList {
jsonResult.OrphanDetails.Events = append(jsonResult.OrphanDetails.Events,
repairEventDetail{ID: event.id, IssueID: event.issueID, EventType: event.eventType})
}
// Handle no orphans case
if stats.total() == 0 { if stats.total() == 0 {
if repairJSON {
jsonResult.Status = "no_orphans"
outputJSONAndExit(jsonResult, 0)
}
fmt.Printf("%s No orphaned references found - database is clean\n", ui.RenderPass("✓")) fmt.Printf("%s No orphaned references found - database is clean\n", ui.RenderPass("✓"))
return return
} }
fmt.Printf("Found %d orphaned reference(s):\n", stats.total()) // Print findings (text mode)
if stats.orphanedDepsIssueID > 0 { if !repairJSON {
fmt.Printf(" • %d dependencies with missing issue_id\n", stats.orphanedDepsIssueID) fmt.Printf("Found %d orphaned reference(s):\n", stats.total())
for _, dep := range orphanedIssueID { if stats.orphanedDepsIssueID > 0 {
fmt.Printf(" - %s → %s\n", dep.issueID, dep.dependsOnID) fmt.Printf(" • %d dependencies with missing issue_id\n", stats.orphanedDepsIssueID)
for _, dep := range orphanedIssueID {
fmt.Printf(" - %s → %s\n", dep.issueID, dep.dependsOnID)
}
} }
} if stats.orphanedDepsDependsOn > 0 {
if stats.orphanedDepsDependsOn > 0 { fmt.Printf(" • %d dependencies with missing depends_on_id\n", stats.orphanedDepsDependsOn)
fmt.Printf(" • %d dependencies with missing depends_on_id\n", stats.orphanedDepsDependsOn) for _, dep := range orphanedDependsOn {
for _, dep := range orphanedDependsOn { fmt.Printf(" - %s → %s\n", dep.issueID, dep.dependsOnID)
fmt.Printf(" - %s → %s\n", dep.issueID, dep.dependsOnID) }
} }
} if stats.orphanedLabels > 0 {
if stats.orphanedLabels > 0 { fmt.Printf(" • %d labels with missing issue_id\n", stats.orphanedLabels)
fmt.Printf(" • %d labels with missing issue_id\n", stats.orphanedLabels) for _, label := range orphanedLabels {
for _, label := range orphanedLabels { fmt.Printf(" - %s: %s\n", label.issueID, label.label)
fmt.Printf(" - %s: %s\n", label.issueID, label.label) }
} }
if stats.orphanedComments > 0 {
fmt.Printf(" • %d comments with missing issue_id\n", stats.orphanedComments)
for _, comment := range orphanedCommentsList {
fmt.Printf(" - %s (by %s)\n", comment.issueID, comment.author)
}
}
if stats.orphanedEvents > 0 {
fmt.Printf(" • %d events with missing issue_id\n", stats.orphanedEvents)
for _, event := range orphanedEventsList {
fmt.Printf(" - %s: %s\n", event.issueID, event.eventType)
}
}
fmt.Println()
} }
fmt.Println()
// Handle dry-run
if repairDryRun { if repairDryRun {
if repairJSON {
jsonResult.Status = "dry_run"
outputJSONAndExit(jsonResult, 0)
}
fmt.Printf("[DRY-RUN] Would delete %d orphaned reference(s)\n", stats.total()) fmt.Printf("[DRY-RUN] Would delete %d orphaned reference(s)\n", stats.total())
return return
} }
// Create backup before destructive operations // Create backup before destructive operations
backupPath := dbPath + ".pre-repair" backupPath := dbPath + ".pre-repair"
fmt.Printf("Creating backup: %s\n", filepath.Base(backupPath)) if !repairJSON {
fmt.Printf("Creating backup: %s\n", filepath.Base(backupPath))
}
if err := copyFile(dbPath, backupPath); err != nil { if err := copyFile(dbPath, backupPath); err != nil {
if repairJSON {
jsonResult.Status = "error"
jsonResult.Error = fmt.Sprintf("creating backup: %v", err)
outputJSONAndExit(jsonResult, 1)
}
fmt.Fprintf(os.Stderr, "Error creating backup: %v\n", err) fmt.Fprintf(os.Stderr, "Error creating backup: %v\n", err)
fmt.Fprintf(os.Stderr, "Aborting repair. Fix backup issue and retry.\n") fmt.Fprintf(os.Stderr, "Aborting repair. Fix backup issue and retry.\n")
os.Exit(1) os.Exit(1)
} }
fmt.Printf(" %s Backup created\n\n", ui.RenderPass("✓")) jsonResult.BackupPath = backupPath
if !repairJSON {
fmt.Printf(" %s Backup created\n\n", ui.RenderPass("✓"))
}
// Apply repairs in a transaction // Apply repairs in a transaction
fmt.Println("Cleaning orphaned references...") if !repairJSON {
fmt.Println("Cleaning orphaned references...")
}
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
if repairJSON {
jsonResult.Status = "error"
jsonResult.Error = fmt.Sprintf("starting transaction: %v", err)
outputJSONAndExit(jsonResult, 1)
}
fmt.Fprintf(os.Stderr, "Error starting transaction: %v\n", err) fmt.Fprintf(os.Stderr, "Error starting transaction: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -171,7 +302,7 @@ func runRepair(cmd *cobra.Command, args []string) {
`) `)
if err != nil { if err != nil {
repairErr = fmt.Errorf("deleting orphaned deps (issue_id): %w", err) repairErr = fmt.Errorf("deleting orphaned deps (issue_id): %w", err)
} else { } else if !repairJSON {
deleted, _ := result.RowsAffected() deleted, _ := result.RowsAffected()
fmt.Printf(" %s Deleted %d dependencies with missing issue_id\n", ui.RenderPass("✓"), deleted) fmt.Printf(" %s Deleted %d dependencies with missing issue_id\n", ui.RenderPass("✓"), deleted)
} }
@@ -191,7 +322,7 @@ func runRepair(cmd *cobra.Command, args []string) {
`) `)
if err != nil { if err != nil {
repairErr = fmt.Errorf("deleting orphaned deps (depends_on_id): %w", err) repairErr = fmt.Errorf("deleting orphaned deps (depends_on_id): %w", err)
} else { } else if !repairJSON {
deleted, _ := result.RowsAffected() deleted, _ := result.RowsAffected()
fmt.Printf(" %s Deleted %d dependencies with missing depends_on_id\n", ui.RenderPass("✓"), deleted) fmt.Printf(" %s Deleted %d dependencies with missing depends_on_id\n", ui.RenderPass("✓"), deleted)
} }
@@ -206,15 +337,50 @@ func runRepair(cmd *cobra.Command, args []string) {
`) `)
if err != nil { if err != nil {
repairErr = fmt.Errorf("deleting orphaned labels: %w", err) repairErr = fmt.Errorf("deleting orphaned labels: %w", err)
} else { } else if !repairJSON {
deleted, _ := result.RowsAffected() deleted, _ := result.RowsAffected()
fmt.Printf(" %s Deleted %d labels with missing issue_id\n", ui.RenderPass("✓"), deleted) fmt.Printf(" %s Deleted %d labels with missing issue_id\n", ui.RenderPass("✓"), deleted)
} }
} }
// Delete orphaned comments
if len(orphanedCommentsList) > 0 && repairErr == nil {
// Comments reference non-existent issues, so no dirty marking needed
result, err := tx.Exec(`
DELETE FROM comments
WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = comments.issue_id)
`)
if err != nil {
repairErr = fmt.Errorf("deleting orphaned comments: %w", err)
} else if !repairJSON {
deleted, _ := result.RowsAffected()
fmt.Printf(" %s Deleted %d comments with missing issue_id\n", ui.RenderPass("✓"), deleted)
}
}
// Delete orphaned events
if len(orphanedEventsList) > 0 && repairErr == nil {
// Events reference non-existent issues, so no dirty marking needed
result, err := tx.Exec(`
DELETE FROM events
WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = events.issue_id)
`)
if err != nil {
repairErr = fmt.Errorf("deleting orphaned events: %w", err)
} else if !repairJSON {
deleted, _ := result.RowsAffected()
fmt.Printf(" %s Deleted %d events with missing issue_id\n", ui.RenderPass("✓"), deleted)
}
}
// Commit or rollback // Commit or rollback
if repairErr != nil { if repairErr != nil {
_ = tx.Rollback() _ = tx.Rollback()
if repairJSON {
jsonResult.Status = "error"
jsonResult.Error = repairErr.Error()
outputJSONAndExit(jsonResult, 1)
}
fmt.Fprintf(os.Stderr, "\n%s Error: %v\n", ui.RenderFail("✗"), repairErr) fmt.Fprintf(os.Stderr, "\n%s Error: %v\n", ui.RenderFail("✗"), repairErr)
fmt.Fprintf(os.Stderr, "Transaction rolled back. Database unchanged.\n") fmt.Fprintf(os.Stderr, "Transaction rolled back. Database unchanged.\n")
fmt.Fprintf(os.Stderr, "Backup available at: %s\n", backupPath) fmt.Fprintf(os.Stderr, "Backup available at: %s\n", backupPath)
@@ -222,19 +388,34 @@ func runRepair(cmd *cobra.Command, args []string) {
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
if repairJSON {
jsonResult.Status = "error"
jsonResult.Error = fmt.Sprintf("committing transaction: %v", err)
outputJSONAndExit(jsonResult, 1)
}
fmt.Fprintf(os.Stderr, "\n%s Error committing transaction: %v\n", ui.RenderFail("✗"), err) fmt.Fprintf(os.Stderr, "\n%s Error committing transaction: %v\n", ui.RenderFail("✗"), err)
fmt.Fprintf(os.Stderr, "Backup available at: %s\n", backupPath) fmt.Fprintf(os.Stderr, "Backup available at: %s\n", backupPath)
os.Exit(1) os.Exit(1)
} }
// Run WAL checkpoint to persist changes // Run WAL checkpoint to persist changes
fmt.Print(" Running WAL checkpoint... ") if !repairJSON {
fmt.Print(" Running WAL checkpoint... ")
}
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
fmt.Printf("%s %v\n", ui.RenderFail("✗"), err) if !repairJSON {
} else { fmt.Printf("%s %v\n", ui.RenderFail("✗"), err)
}
} else if !repairJSON {
fmt.Printf("%s\n", ui.RenderPass("✓")) fmt.Printf("%s\n", ui.RenderPass("✓"))
} }
// Final output
if repairJSON {
jsonResult.Status = "success"
outputJSONAndExit(jsonResult, 0)
}
fmt.Println() fmt.Println()
fmt.Printf("%s Repair complete. Try running 'bd doctor' to verify.\n", ui.RenderPass("✓")) fmt.Printf("%s Repair complete. Try running 'bd doctor' to verify.\n", ui.RenderPass("✓"))
fmt.Printf("Backup preserved at: %s\n", filepath.Base(backupPath)) fmt.Printf("Backup preserved at: %s\n", filepath.Base(backupPath))
@@ -245,10 +426,12 @@ type repairStats struct {
orphanedDepsIssueID int orphanedDepsIssueID int
orphanedDepsDependsOn int orphanedDepsDependsOn int
orphanedLabels int orphanedLabels int
orphanedComments int
orphanedEvents int
} }
func (s repairStats) total() int { func (s repairStats) total() int {
return s.orphanedDepsIssueID + s.orphanedDepsDependsOn + s.orphanedLabels return s.orphanedDepsIssueID + s.orphanedDepsDependsOn + s.orphanedLabels + s.orphanedComments + s.orphanedEvents
} }
// orphanedDep represents an orphaned dependency // orphanedDep represents an orphaned dependency
@@ -263,6 +446,70 @@ type orphanedLabel struct {
label string label string
} }
// orphanedComment represents an orphaned comment
type orphanedComment struct {
id int
issueID string
author string
}
// orphanedEvent represents an orphaned event
type orphanedEvent struct {
id int
issueID string
eventType string
}
// repairResult is the JSON output structure
type repairResult struct {
DatabasePath string `json:"database_path"`
DryRun bool `json:"dry_run"`
OrphanCounts repairOrphanCounts `json:"orphan_counts"`
OrphanDetails repairOrphanDetails `json:"orphan_details"`
Status string `json:"status"` // "success", "no_orphans", "dry_run", "error"
BackupPath string `json:"backup_path,omitempty"`
Error string `json:"error,omitempty"`
}
type repairOrphanCounts struct {
DependenciesIssueID int `json:"dependencies_issue_id"`
DependenciesDependsOn int `json:"dependencies_depends_on"`
Labels int `json:"labels"`
Comments int `json:"comments"`
Events int `json:"events"`
Total int `json:"total"`
}
type repairOrphanDetails struct {
DependenciesIssueID []repairDepDetail `json:"dependencies_issue_id,omitempty"`
DependenciesDependsOn []repairDepDetail `json:"dependencies_depends_on,omitempty"`
Labels []repairLabelDetail `json:"labels,omitempty"`
Comments []repairCommentDetail `json:"comments,omitempty"`
Events []repairEventDetail `json:"events,omitempty"`
}
type repairDepDetail struct {
IssueID string `json:"issue_id"`
DependsOnID string `json:"depends_on_id"`
}
type repairLabelDetail struct {
IssueID string `json:"issue_id"`
Label string `json:"label"`
}
type repairCommentDetail struct {
ID int `json:"id"`
IssueID string `json:"issue_id"`
Author string `json:"author"`
}
type repairEventDetail struct {
ID int `json:"id"`
IssueID string `json:"issue_id"`
EventType string `json:"event_type"`
}
// openRepairDB opens SQLite directly for repair, bypassing all beads layer code // openRepairDB opens SQLite directly for repair, bypassing all beads layer code
func openRepairDB(dbPath string) (*sql.DB, error) { func openRepairDB(dbPath string) (*sql.DB, error) {
// Build connection string with pragmas // Build connection string with pragmas
@@ -349,3 +596,49 @@ func findOrphanedLabels(db *sql.DB) ([]orphanedLabel, error) {
return labels, rows.Err() return labels, rows.Err()
} }
// findOrphanedComments finds comments where issue_id doesn't exist
func findOrphanedComments(db *sql.DB) ([]orphanedComment, error) {
rows, err := db.Query(`
SELECT c.id, c.issue_id, c.author
FROM comments c
WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = c.issue_id)
`)
if err != nil {
return nil, err
}
defer rows.Close()
var comments []orphanedComment
for rows.Next() {
var comment orphanedComment
if err := rows.Scan(&comment.id, &comment.issueID, &comment.author); err != nil {
return nil, err
}
comments = append(comments, comment)
}
return comments, rows.Err()
}
// findOrphanedEvents finds events where issue_id doesn't exist
func findOrphanedEvents(db *sql.DB) ([]orphanedEvent, error) {
rows, err := db.Query(`
SELECT e.id, e.issue_id, e.event_type
FROM events e
WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = e.issue_id)
`)
if err != nil {
return nil, err
}
defer rows.Close()
var events []orphanedEvent
for rows.Next() {
var event orphanedEvent
if err := rows.Scan(&event.id, &event.issueID, &event.eventType); err != nil {
return nil, err
}
events = append(events, event)
}
return events, rows.Err()
}