Files
beads/cmd/bd/repair_deps.go
Steve Yegge 57253f93a3 Context propagation with graceful cancellation (bd-rtp, bd-yb8, bd-2o2)
Complete implementation of signal-aware context propagation for graceful
cancellation across all commands and storage operations.

Key changes:

1. Signal-aware contexts (bd-rtp):
   - Added rootCtx/rootCancel in main.go using signal.NotifyContext()
   - Set up in PersistentPreRun, cancelled in PersistentPostRun
   - Daemon uses same pattern in runDaemonLoop()
   - Handles SIGINT/SIGTERM for graceful shutdown

2. Context propagation (bd-yb8):
   - All commands now use rootCtx instead of context.Background()
   - sqlite.New() receives context for cancellable operations
   - Database operations respect context cancellation
   - Storage layer propagates context through all queries

3. Cancellation tests (bd-2o2):
   - Added import_cancellation_test.go with comprehensive tests
   - Added export cancellation test in export_test.go
   - Tests verify database integrity after cancellation
   - All cancellation tests passing

Fixes applied during review:
   - Fixed rootCtx lifecycle (removed premature defer from PersistentPreRun)
   - Fixed test context contamination (reset rootCtx in test cleanup)
   - Fixed export tests missing context setup

Impact:
   - Pressing Ctrl+C during import/export now cancels gracefully
   - No database corruption or hanging transactions
   - Clean shutdown of all operations

Tested:
   - go build ./cmd/bd ✓
   - go test ./cmd/bd -run TestImportCancellation ✓
   - go test ./cmd/bd -run TestExportCommand ✓
   - Manual Ctrl+C testing verified

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 21:57:23 -05:00

176 lines
5.4 KiB
Go

// Package main implements the bd CLI dependency repair command.
package main
import (
"fmt"
"os"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
var repairDepsCmd = &cobra.Command{
Use: "repair-deps",
Short: "Find and fix orphaned dependency references",
Long: `Scans all issues for dependencies pointing to non-existent issues.
Reports orphaned dependencies and optionally removes them with --fix.
Interactive mode with --interactive prompts for each orphan.`,
Run: func(cmd *cobra.Command, args []string) {
fix, _ := cmd.Flags().GetBool("fix")
interactive, _ := cmd.Flags().GetBool("interactive")
// If daemon is running but doesn't support this command, use direct storage
if daemonClient != nil && store == nil {
var err error
store, err = sqlite.New(rootCtx, dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
os.Exit(1)
}
defer func() { _ = store.Close() }()
}
ctx := rootCtx
// Get all dependency records
allDeps, err := store.GetAllDependencyRecords(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to get dependencies: %v\n", err)
os.Exit(1)
}
// Get all issues to check existence
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to list issues: %v\n", err)
os.Exit(1)
}
// Build set of valid issue IDs
validIDs := make(map[string]bool)
for _, issue := range issues {
validIDs[issue.ID] = true
}
// Find orphaned dependencies
type orphan struct {
issueID string
dependsOnID string
depType types.DependencyType
}
var orphans []orphan
for issueID, deps := range allDeps {
if !validIDs[issueID] {
// The issue itself doesn't exist, skip (will be cleaned up separately)
continue
}
for _, dep := range deps {
if !validIDs[dep.DependsOnID] {
orphans = append(orphans, orphan{
issueID: dep.IssueID,
dependsOnID: dep.DependsOnID,
depType: dep.Type,
})
}
}
}
if jsonOutput {
result := map[string]interface{}{
"orphans_found": len(orphans),
"orphans": []map[string]string{},
}
if len(orphans) > 0 {
orphanList := make([]map[string]string, len(orphans))
for i, o := range orphans {
orphanList[i] = map[string]string{
"issue_id": o.issueID,
"depends_on_id": o.dependsOnID,
"type": string(o.depType),
}
}
result["orphans"] = orphanList
}
if fix || interactive {
result["fixed"] = len(orphans)
}
outputJSON(result)
return
}
// Report results
if len(orphans) == 0 {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("\n%s No orphaned dependencies found\n\n", green("✓"))
return
}
yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s Found %d orphaned dependencies:\n\n", yellow("⚠"), len(orphans))
for i, o := range orphans {
fmt.Printf("%d. %s → %s (%s) [%s does not exist]\n",
i+1, o.issueID, o.dependsOnID, o.depType, o.dependsOnID)
}
fmt.Println()
// Fix if requested
if interactive {
fixed := 0
for _, o := range orphans {
fmt.Printf("Remove dependency %s → %s (%s)? [y/N]: ", o.issueID, o.dependsOnID, o.depType)
var response string
_, _ = fmt.Scanln(&response)
if response == "y" || response == "Y" {
// Use direct SQL to remove orphaned dependencies
// RemoveDependency tries to mark the depends_on issue as dirty, which fails for orphans
db := store.UnderlyingDB()
_, err := db.ExecContext(ctx, "DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
o.issueID, o.dependsOnID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error removing dependency: %v\n", err)
} else {
// Mark the issue as dirty
_, _ = db.ExecContext(ctx, "INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?)", o.issueID)
fixed++
}
}
}
markDirtyAndScheduleFlush()
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("\n%s Fixed %d orphaned dependencies\n\n", green("✓"), fixed)
} else if fix {
db := store.UnderlyingDB()
for _, o := range orphans {
// Use direct SQL to remove orphaned dependencies
_, err := db.ExecContext(ctx, "DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
o.issueID, o.dependsOnID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error removing dependency %s → %s: %v\n",
o.issueID, o.dependsOnID, err)
} else {
// Mark the issue as dirty
_, _ = db.ExecContext(ctx, "INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?)", o.issueID)
}
}
markDirtyAndScheduleFlush()
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Fixed %d orphaned dependencies\n\n", green("✓"), len(orphans))
} else {
fmt.Printf("Run with --fix to automatically remove orphaned dependencies\n")
fmt.Printf("Run with --interactive to review each dependency\n\n")
}
},
}
func init() {
repairDepsCmd.Flags().Bool("fix", false, "Automatically remove orphaned dependencies")
repairDepsCmd.Flags().Bool("interactive", false, "Interactively review each orphaned dependency")
repairDepsCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output repair results in JSON format")
rootCmd.AddCommand(repairDepsCmd)
}