diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index 2b46b4cc..9adb8a7b 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -64,7 +64,7 @@ {"id":"bd-54","title":"Fix TestTwoCloneCollision timeout","description":"","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-28T14:11:25.219607-07:00","updated_at":"2025-10-28T16:12:26.286611-07:00","closed_at":"2025-10-28T16:12:26.286611-07:00"} {"id":"bd-55","title":"Split internal/rpc/server.go into focused modules","description":"The file `internal/rpc/server.go` is 2,273 lines with 50+ methods, making it difficult to navigate and prone to merge conflicts. Split into 8 focused files with clear responsibilities.\n\nCurrent structure: Single 2,273-line file with:\n- Connection handling\n- Request routing\n- All 40+ RPC method implementations\n- Storage caching\n- Health checks \u0026 metrics\n- Cleanup loops\n\nTarget structure:\n```\ninternal/rpc/\n├── server.go # Core server, connection handling (~300 lines)\n├── methods_issue.go # Issue operations (~400 lines)\n├── methods_deps.go # Dependency operations (~200 lines)\n├── methods_labels.go # Label operations (~150 lines)\n├── methods_ready.go # Ready work queries (~150 lines)\n├── methods_compact.go # Compaction operations (~200 lines)\n├── methods_comments.go # Comment operations (~150 lines)\n├── storage_cache.go # Storage caching logic (~300 lines)\n└── health.go # Health \u0026 metrics (~200 lines)\n```\n\nMigration strategy:\n1. Create new files with appropriate methods\n2. Keep `server.go` as main file with core server logic\n3. Test incrementally after each file split\n4. Final verification with full test suite","acceptance_criteria":"- All 50 methods split into appropriate files\n- Each file \u003c500 LOC\n- All methods remain on `*Server` receiver (no behavior change)\n- All tests pass: `go test ./internal/rpc/...`\n- Verify daemon works: start daemon, run operations, check health\n- Update internal documentation if needed\n- No change to public API","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T14:21:37.51524-07:00","updated_at":"2025-10-28T14:21:37.51524-07:00","closed_at":"2025-10-28T14:11:04.399811-07:00"} {"id":"bd-57","title":"bd resolve-conflicts - Git merge conflict resolver","description":"Automatically resolve JSONL merge conflicts.\n\nModes:\n- Mechanical: ID remapping (no AI)\n- AI-assisted: Smart merge/keep decisions\n- Interactive: Review each conflict\n\nHandles \u003c\u003c\u003c\u003c\u003c\u003c\u003c conflict markers in .beads/beads.jsonl\n\nFiles: cmd/bd/resolve_conflicts.go (new)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.457619-07:00","updated_at":"2025-10-28T15:47:33.037021-07:00","closed_at":"2025-10-28T15:47:33.037021-07:00"} -{"id":"bd-58","title":"bd repair-deps - Orphaned dependency cleaner","description":"Find and fix orphaned dependency references.\n\nImplementation:\n- Scan all issues for dependencies pointing to non-existent issues\n- Report orphaned refs\n- Auto-fix with --fix flag\n- Interactive mode with --interactive\n\nFiles: cmd/bd/repair_deps.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.458319-07:00","updated_at":"2025-10-28T14:48:17.458319-07:00"} +{"id":"bd-58","title":"bd repair-deps - Orphaned dependency cleaner","description":"Find and fix orphaned dependency references.\n\nImplementation:\n- Scan all issues for dependencies pointing to non-existent issues\n- Report orphaned refs\n- Auto-fix with --fix flag\n- Interactive mode with --interactive\n\nFiles: cmd/bd/repair_deps.go (new)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.458319-07:00","updated_at":"2025-10-29T12:49:30.175357-07:00","closed_at":"2025-10-29T12:49:30.175357-07:00"} {"id":"bd-59","title":"bd find-duplicates - AI-powered duplicate detection","description":"Find semantically duplicate issues.\n\nApproaches:\n1. Mechanical: Exact title/description matching\n2. Embeddings: Cosine similarity (cheap, scalable)\n3. AI: LLM-based semantic comparison (expensive, accurate)\n\nUses embeddings by default for \u003e100 issues.\n\nFiles: cmd/bd/find_duplicates.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.45938-07:00","updated_at":"2025-10-28T14:48:17.45938-07:00"} {"id":"bd-6","title":"Add optional post-merge git hook example for bd sync","description":"Create example git hook that auto-runs bd sync after git pull/merge.\n\nAdd to examples/git-hooks/:\n- post-merge hook that checks if .beads/issues.jsonl changed\n- If changed: run `bd sync` automatically\n- Make it optional/documented (not auto-installed)\n\nBenefits:\n- Zero-friction sync after git pull\n- Complements auto-detection as belt-and-suspenders\n\nNote: post-merge hook already exists for pre-commit/post-merge. Extend it to support sync.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-25T22:47:14.668842-07:00","updated_at":"2025-10-27T22:22:23.814647-07:00"} {"id":"bd-60","title":"bd validate - Comprehensive health check","description":"Run all validation checks in one command.\n\nChecks:\n- Duplicates\n- Orphaned dependencies\n- Test pollution\n- Git conflicts\n\nSupports --fix-all for auto-repair.\n\nDepends on bd-108, bd-115, bd-113, bd-153.\n\nFiles: cmd/bd/validate.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.461747-07:00","updated_at":"2025-10-28T16:26:17.484911-07:00"} @@ -86,7 +86,7 @@ {"id":"bd-75","title":"Platform tests: Linux, macOS, Windows","description":"Test event-driven mode on all platforms. Verify inotify (Linux), FSEvents (macOS), ReadDirectoryChangesW (Windows). Test fallback behavior on each.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.431943-07:00","updated_at":"2025-10-28T16:20:02.431943-07:00","dependencies":[{"issue_id":"bd-75","depends_on_id":"bd-85","type":"parent-child","created_at":"2025-10-29T11:26:40.583085-07:00","created_by":"daemon"}]} {"id":"bd-76","title":"Remove unreachable RPC methods","description":"Several RPC server and client methods are unreachable and should be removed:\n\nServer methods (internal/rpc/server.go):\n- `Server.GetLastImportTime` (line 2116)\n- `Server.SetLastImportTime` (line 2123)\n- `Server.findJSONLPath` (line 2255)\n\nClient methods (internal/rpc/client.go):\n- `Client.Import` (line 311) - RPC import not used (daemon uses autoimport)\n\nEvidence:\n```bash\ngo run golang.org/x/tools/cmd/deadcode@latest -test ./...\n```\n\nImpact: Removes ~80 LOC of unused RPC code","acceptance_criteria":"- Remove the 4 unreachable methods (~80 LOC total)\n- Verify no callers: `grep -r \"GetLastImportTime\\|SetLastImportTime\\|findJSONLPath\" .`\n- All tests pass: `go test ./internal/rpc/...`\n- Daemon functionality works: test daemon start/stop/operations","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T16:20:02.432202-07:00","updated_at":"2025-10-28T16:20:02.432202-07:00"} {"id":"bd-77","title":"Integration test: mutation to export latency","description":"Measure time from bd create to JSONL update. Verify \u003c500ms latency. Test with multiple rapid mutations to verify batching.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.432509-07:00","updated_at":"2025-10-28T16:20:02.432509-07:00","dependencies":[{"issue_id":"bd-77","depends_on_id":"bd-85","type":"parent-child","created_at":"2025-10-29T11:26:40.593097-07:00","created_by":"daemon"}]} -{"id":"bd-78","title":"Unit tests for FileWatcher","description":"Test watcher detects JSONL changes. Test git ref changes trigger import. Test debounce integration. Test watcher recovery from file removal/rename.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.432873-07:00","updated_at":"2025-10-29T11:34:42.037965-07:00","closed_at":"2025-10-29T11:34:42.037965-07:00","dependencies":[{"issue_id":"bd-78","depends_on_id":"bd-85","type":"parent-child","created_at":"2025-10-29T11:26:40.596131-07:00","created_by":"daemon"}]} +{"id":"bd-78","title":"Unit tests for FileWatcher","description":"Test watcher detects JSONL changes. Test git ref changes trigger import. Test debounce integration. Test watcher recovery from file removal/rename.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.432873-07:00","updated_at":"2025-10-29T12:42:10.963527-07:00","closed_at":"2025-10-29T12:42:10.963527-07:00","dependencies":[{"issue_id":"bd-78","depends_on_id":"bd-85","type":"parent-child","created_at":"2025-10-29T11:26:40.596131-07:00","created_by":"daemon"}]} {"id":"bd-79","title":"Update AGENTS.md with event-driven mode","description":"Document BEADS_DAEMON_MODE env var. Explain opt-in during Phase 1. Add troubleshooting for watcher failures.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T16:20:02.433145-07:00","updated_at":"2025-10-28T16:20:02.433145-07:00","dependencies":[{"issue_id":"bd-79","depends_on_id":"bd-85","type":"parent-child","created_at":"2025-10-29T11:26:40.601884-07:00","created_by":"daemon"}]} {"id":"bd-8","title":"Daemon fails to auto-import after git pull updates JSONL","description":"After git pull updates .beads/issues.jsonl, daemon doesn't automatically re-import changes, causing stale data to be shown until next sync cycle (up to 5 minutes).\n\nReproduction:\n1. Repo A: Close issues, export, commit, push\n2. Repo B: git pull (successfully updates .beads/issues.jsonl)\n3. bd show \u003cissue\u003e shows OLD status from daemon's SQLite db\n4. JSONL on disk has correct new status\n\nRoot cause: Daemon sync cycle runs on timer (5min). When user manually runs git pull, daemon doesn't detect JSONL was updated externally and continues serving stale data from SQLite.\n\nImpact:\n- High for AI agents using beads in git workflows\n- Breaks fundamental git-as-source-of-truth model\n- Confusing UX: git log shows commit, bd shows old state\n- Data consistency issues between JSONL and daemon\n\nSee WYVERN_SYNC_ISSUE.md for full analysis.","design":"Three possible solutions:\n\nOption 1: Auto-detect and re-import (recommended)\n- Before serving any bd command, check if .beads/issues.jsonl mtime \u003e last import time\n- If newer, auto-import before processing request\n- Fast check, minimal overhead\n\nOption 2: File watcher in daemon\n- Daemon watches .beads/issues.jsonl for mtime changes\n- Auto-imports when file changes\n- More complex, requires file watching infrastructure\n\nOption 3: Explicit sync command\n- User runs `bd sync` after git pull\n- Manual, error-prone, defeats automation\n\nRecommended: Option 1 (auto-detect) + Option 3 (explicit sync) as fallback.","acceptance_criteria":"1. After git pull updates .beads/issues.jsonl, next bd command sees fresh data\n2. No manual import or daemon restart required\n3. Performance impact \u003c 10ms per command (mtime check is fast)\n4. Works in both daemon and non-daemon modes\n5. Test: Two repo clones, update in one, pull in other, verify immediate sync","notes":"**Current Status (2025-10-26):**\n\n✅ **Completed (bd-128):**\n- Created internal/autoimport package with staleness detection\n- Daemon can detect when JSONL is newer than last import\n- Infrastructure exists to call import logic\n\n❌ **Remaining Work:**\nThe daemon's importFunc in server.go (line 2096-2102) is a stub that just logs a notice. It needs to actually import the issues.\n\n**Problem:** \n- importIssuesCore is in cmd/bd package, not accessible from internal/rpc\n- daemon's handleImport() returns 'not yet implemented' error\n\n**Two approaches:**\n1. Move importIssuesCore to internal/import package (shares with daemon)\n2. Use storage layer directly in daemon (create/update issues via Storage interface)\n\n**Blocker:** \nThis is the critical bug causing data corruption:\n- Agent A pushes changes\n- Agent B does git pull\n- Agent B's daemon serves stale SQLite data\n- Agent B exports stale data back to JSONL, overwriting Agent A's changes\n- Agent B pushes, losing Agent A's work\n\n**Next Steps:**\n1. Choose approach (probably #1 - move importIssuesCore to internal/import)\n2. Implement real importFunc in daemon's checkAndAutoImportIfStale()\n3. Test with two-repo scenario (push from A, pull in B, verify B sees changes)\n4. Ensure no data corruption in multi-agent workflows","status":"in_progress","priority":0,"issue_type":"epic","created_at":"2025-10-25T23:13:12.270766-07:00","updated_at":"2025-10-27T22:22:23.815209-07:00"} {"id":"bd-80","title":"Add mutation channel to internal/rpc/server.go","description":"Add mutationChan chan MutationEvent to Server struct. Emit events on CreateIssue, UpdateIssue, DeleteIssue, AddComment. Non-blocking send with default case for full channel.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.433388-07:00","updated_at":"2025-10-29T11:22:18.314571-07:00","closed_at":"2025-10-29T11:22:18.314571-07:00"} diff --git a/cmd/bd/repair_deps.go b/cmd/bd/repair_deps.go index bccaa009..de51aa9e 100644 --- a/cmd/bd/repair_deps.go +++ b/cmd/bd/repair_deps.go @@ -1,3 +1,4 @@ +// Package main implements the bd CLI dependency repair command. package main import ( @@ -5,158 +6,170 @@ 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: `Find issues that reference non-existent dependencies and optionally remove them. - -This command scans all issues for dependency references (both blocks and related-to) -that point to issues that no longer exist in the database. - -Example: - bd repair-deps # Show orphaned dependencies - bd repair-deps --fix # Remove orphaned references - bd repair-deps --json # Output in JSON format`, - Run: func(cmd *cobra.Command, _ []string) { - // Check daemon mode - not supported yet (uses direct storage access) - if daemonClient != nil { - fmt.Fprintf(os.Stderr, "Error: repair-deps command not yet supported in daemon mode\n") - fmt.Fprintf(os.Stderr, "Use: bd --no-daemon repair-deps\n") - os.Exit(1) - } + 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(dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err) + os.Exit(1) + } + defer func() { _ = store.Close() }() + } ctx := context.Background() - // Get all issues - allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) + // Get all dependency records + allDeps, err := store.GetAllDependencyRecords(ctx) if err != nil { - fmt.Fprintf(os.Stderr, "Error fetching issues: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: failed to get dependencies: %v\n", err) os.Exit(1) } - // Build ID existence map - existingIDs := make(map[string]bool) - for _, issue := range allIssues { - existingIDs[issue.ID] = true + // 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 orphanedDep struct { - IssueID string - OrphanedID string - DepType string + type orphan struct { + issueID string + dependsOnID string + depType types.DependencyType } - - var orphaned []orphanedDep + var orphans []orphan - for _, issue := range allIssues { - // Check dependencies - for _, dep := range issue.Dependencies { - if !existingIDs[dep.DependsOnID] { - orphaned = append(orphaned, orphanedDep{ - IssueID: issue.ID, - OrphanedID: dep.DependsOnID, - DepType: string(dep.Type), + 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, }) } } } - // Output results if jsonOutput { result := map[string]interface{}{ - "orphaned_count": len(orphaned), - "fixed": fix, - "orphaned_deps": []map[string]interface{}{}, + "orphans_found": len(orphans), + "orphans": []map[string]string{}, } - - for _, o := range orphaned { - result["orphaned_deps"] = append(result["orphaned_deps"].([]map[string]interface{}), map[string]interface{}{ - "issue_id": o.IssueID, - "orphaned_id": o.OrphanedID, - "dep_type": o.DepType, - }) + 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 } - // Human-readable output - if len(orphaned) == 0 { - fmt.Println("No orphaned dependencies found!") + // Report results + if len(orphans) == 0 { + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("\n%s No orphaned dependencies found\n\n", green("✓")) return } - fmt.Printf("Found %d orphaned dependencies:\n\n", len(orphaned)) - for _, o := range orphaned { - fmt.Printf(" %s: depends on %s (%s) - DELETED\n", o.IssueID, o.OrphanedID, o.DepType) + 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() - if !fix { - fmt.Printf("\nRun 'bd repair-deps --fix' to remove these references.\n") - return - } - - // Fix orphaned dependencies - fmt.Printf("\nRemoving orphaned dependencies...\n") - - // Group by issue for efficient updates - orphansByIssue := make(map[string][]string) - for _, o := range orphaned { - orphansByIssue[o.IssueID] = append(orphansByIssue[o.IssueID], o.OrphanedID) - } - - fixed := 0 - for issueID, orphanedIDs := range orphansByIssue { - // Get current issue to verify - issue, err := store.GetIssue(ctx, issueID) - if err != nil { - fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", issueID, err) - continue - } - - // Collect orphaned dependency IDs to remove - orphanedSet := make(map[string]bool) - for _, orphanedID := range orphanedIDs { - orphanedSet[orphanedID] = true - } - - // Build list of dependencies to keep - validDeps := []*types.Dependency{} - for _, dep := range issue.Dependencies { - if !orphanedSet[dep.DependsOnID] { - validDeps = append(validDeps, dep) + // 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++ + } } } - - // Update via storage layer - // We need to remove each orphaned dependency individually - for _, orphanedID := range orphanedIDs { - if err := store.RemoveDependency(ctx, issueID, orphanedID, actor); err != nil { - fmt.Fprintf(os.Stderr, "Error removing %s from %s: %v\n", orphanedID, issueID, err) - continue + 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) } - - fmt.Printf("✓ Removed %s from %s dependencies\n", orphanedID, issueID) - fixed++ } + 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") } - - // Schedule auto-flush - markDirtyAndScheduleFlush() - - fmt.Printf("\nRepaired %d orphaned dependencies.\n", fixed) }, } func init() { - repairDepsCmd.Flags().Bool("fix", false, "Remove orphaned dependency references") + repairDepsCmd.Flags().Bool("fix", false, "Automatically remove orphaned dependencies") + repairDepsCmd.Flags().Bool("interactive", false, "Interactively review each orphaned dependency") rootCmd.AddCommand(repairDepsCmd) } diff --git a/cmd/bd/repair_deps_test.go b/cmd/bd/repair_deps_test.go new file mode 100644 index 00000000..7c908693 --- /dev/null +++ b/cmd/bd/repair_deps_test.go @@ -0,0 +1,393 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +func TestRepairDeps_NoOrphans(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, ".beads", "beads.db") + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + t.Fatal(err) + } + + store, err := sqlite.New(dbPath) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + ctx := context.Background() + + // Initialize database + store.SetConfig(ctx, "issue_prefix", "test-") + + // Create two issues with valid dependency + i1 := &types.Issue{Title: "Issue 1", Priority: 1, Status: "open", IssueType: "task"} + store.CreateIssue(ctx, i1, "test") + i2 := &types.Issue{Title: "Issue 2", Priority: 1, Status: "open", IssueType: "task"} + store.CreateIssue(ctx, i2, "test") + store.AddDependency(ctx, &types.Dependency{ + IssueID: i2.ID, + DependsOnID: i1.ID, + Type: types.DepBlocks, + }, "test") + + // Get all dependency records + allDeps, err := store.GetAllDependencyRecords(ctx) + if err != nil { + t.Fatal(err) + } + + // Get all issues + issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) + if err != nil { + t.Fatal(err) + } + + // Build valid ID set + validIDs := make(map[string]bool) + for _, issue := range issues { + validIDs[issue.ID] = true + } + + // Find orphans + orphanCount := 0 + for issueID, deps := range allDeps { + if !validIDs[issueID] { + continue + } + for _, dep := range deps { + if !validIDs[dep.DependsOnID] { + orphanCount++ + } + } + } + + if orphanCount != 0 { + t.Errorf("Expected 0 orphans, got %d", orphanCount) + } +} + +func TestRepairDeps_FindOrphans(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, ".beads", "beads.db") + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + t.Fatal(err) + } + + store, err := sqlite.New(dbPath) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + ctx := context.Background() + + // Initialize database + store.SetConfig(ctx, "issue_prefix", "test-") + + // Create two issues + i1 := &types.Issue{Title: "Issue 1", Priority: 1, Status: "open", IssueType: "task"} + if err := store.CreateIssue(ctx, i1, "test"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + t.Logf("Created i1: %s", i1.ID) + + i2 := &types.Issue{Title: "Issue 2", Priority: 1, Status: "open", IssueType: "task"} + if err := store.CreateIssue(ctx, i2, "test"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + t.Logf("Created i2: %s", i2.ID) + + // Add dependency + err = store.AddDependency(ctx, &types.Dependency{ + IssueID: i2.ID, + DependsOnID: i1.ID, + Type: types.DepBlocks, + }, "test") + if err != nil { + t.Fatalf("AddDependency failed: %v", err) + } + + // Manually create orphaned dependency by directly inserting invalid reference + // This simulates corruption or import errors + db := store.UnderlyingDB() + _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = OFF") + if err != nil { + t.Fatal(err) + } + // Insert a dependency pointing to a non-existent issue + _, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by) + VALUES (?, 'nonexistent-123', 'blocks', datetime('now'), 'test')`, i2.ID) + if err != nil { + t.Fatalf("Failed to insert orphaned dependency: %v", err) + } + _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON") + if err != nil { + t.Fatal(err) + } + + // Verify the orphan was actually inserted + var count int + err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dependencies WHERE depends_on_id = 'nonexistent-123'").Scan(&count) + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("Orphan dependency not inserted, count=%d", count) + } + + // Get all dependency records + allDeps, err := store.GetAllDependencyRecords(ctx) + if err != nil { + t.Fatal(err) + } + + t.Logf("Got %d issues with dependencies", len(allDeps)) + for issueID, deps := range allDeps { + t.Logf("Issue %s has %d dependencies", issueID, len(deps)) + for _, dep := range deps { + t.Logf(" -> %s (%s)", dep.DependsOnID, dep.Type) + } + } + + // Get all issues + issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) + if err != nil { + t.Fatal(err) + } + + // Build valid ID set + validIDs := make(map[string]bool) + for _, issue := range issues { + validIDs[issue.ID] = true + } + t.Logf("Valid issue IDs: %v", validIDs) + + // Find orphans + orphanCount := 0 + for issueID, deps := range allDeps { + if !validIDs[issueID] { + t.Logf("Skipping %s - issue itself doesn't exist", issueID) + continue + } + for _, dep := range deps { + if !validIDs[dep.DependsOnID] { + t.Logf("Found orphan: %s -> %s", dep.IssueID, dep.DependsOnID) + orphanCount++ + } + } + } + + if orphanCount != 1 { + t.Errorf("Expected 1 orphan, got %d", orphanCount) + } +} + +func TestRepairDeps_FixOrphans(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, ".beads", "beads.db") + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + t.Fatal(err) + } + + store, err := sqlite.New(dbPath) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + ctx := context.Background() + + // Initialize database + store.SetConfig(ctx, "issue_prefix", "test-") + + // Create three issues + i1 := &types.Issue{Title: "Issue 1", Priority: 1, Status: "open", IssueType: "task"} + store.CreateIssue(ctx, i1, "test") + i2 := &types.Issue{Title: "Issue 2", Priority: 1, Status: "open", IssueType: "task"} + store.CreateIssue(ctx, i2, "test") + i3 := &types.Issue{Title: "Issue 3", Priority: 1, Status: "open", IssueType: "task"} + store.CreateIssue(ctx, i3, "test") + + // Add dependencies + store.AddDependency(ctx, &types.Dependency{ + IssueID: i2.ID, + DependsOnID: i1.ID, + Type: types.DepBlocks, + }, "test") + store.AddDependency(ctx, &types.Dependency{ + IssueID: i3.ID, + DependsOnID: i1.ID, + Type: types.DepBlocks, + }, "test") + + // Manually create orphaned dependencies by inserting invalid references + db := store.UnderlyingDB() + db.Exec("PRAGMA foreign_keys = OFF") + _, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by) + VALUES (?, 'nonexistent-123', 'blocks', datetime('now'), 'test')`, i2.ID) + if err != nil { + t.Fatal(err) + } + _, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by) + VALUES (?, 'nonexistent-456', 'blocks', datetime('now'), 'test')`, i3.ID) + if err != nil { + t.Fatal(err) + } + db.Exec("PRAGMA foreign_keys = ON") + + // Find and fix orphans + allDeps, _ := store.GetAllDependencyRecords(ctx) + issues, _ := store.SearchIssues(ctx, "", types.IssueFilter{}) + + validIDs := make(map[string]bool) + for _, issue := range issues { + validIDs[issue.ID] = true + } + + type orphan struct { + issueID string + dependsOnID string + } + var orphans []orphan + + for issueID, deps := range allDeps { + if !validIDs[issueID] { + continue + } + for _, dep := range deps { + if !validIDs[dep.DependsOnID] { + orphans = append(orphans, orphan{ + issueID: dep.IssueID, + dependsOnID: dep.DependsOnID, + }) + } + } + } + + if len(orphans) != 2 { + t.Fatalf("Expected 2 orphans before fix, got %d", len(orphans)) + } + + // Fix orphans using direct SQL (like the command does) + for _, o := range orphans { + _, delErr := db.ExecContext(ctx, "DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?", + o.issueID, o.dependsOnID) + if delErr != nil { + t.Errorf("Failed to remove orphan: %v", delErr) + } + } + + // Verify orphans removed + allDeps, _ = store.GetAllDependencyRecords(ctx) + orphanCount := 0 + for issueID, deps := range allDeps { + if !validIDs[issueID] { + continue + } + for _, dep := range deps { + if !validIDs[dep.DependsOnID] { + orphanCount++ + } + } + } + + if orphanCount != 0 { + t.Errorf("Expected 0 orphans after fix, got %d", orphanCount) + } +} + +func TestRepairDeps_MultipleTypes(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, ".beads", "beads.db") + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + t.Fatal(err) + } + + store, err := sqlite.New(dbPath) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + ctx := context.Background() + + // Initialize database + store.SetConfig(ctx, "issue_prefix", "test-") + + // Create issues + i1 := &types.Issue{Title: "Issue 1", Priority: 1, Status: "open", IssueType: "task"} + store.CreateIssue(ctx, i1, "test") + i2 := &types.Issue{Title: "Issue 2", Priority: 1, Status: "open", IssueType: "task"} + store.CreateIssue(ctx, i2, "test") + i3 := &types.Issue{Title: "Issue 3", Priority: 1, Status: "open", IssueType: "task"} + store.CreateIssue(ctx, i3, "test") + + // Add different dependency types + store.AddDependency(ctx, &types.Dependency{ + IssueID: i2.ID, + DependsOnID: i1.ID, + Type: types.DepBlocks, + }, "test") + store.AddDependency(ctx, &types.Dependency{ + IssueID: i3.ID, + DependsOnID: i1.ID, + Type: types.DepRelated, + }, "test") + + // Manually create orphaned dependencies with different types + db := store.UnderlyingDB() + db.Exec("PRAGMA foreign_keys = OFF") + _, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by) + VALUES (?, 'nonexistent-blocks', 'blocks', datetime('now'), 'test')`, i2.ID) + if err != nil { + t.Fatal(err) + } + _, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by) + VALUES (?, 'nonexistent-related', 'related', datetime('now'), 'test')`, i3.ID) + if err != nil { + t.Fatal(err) + } + db.Exec("PRAGMA foreign_keys = ON") + + // Find orphans + allDeps, _ := store.GetAllDependencyRecords(ctx) + issues, _ := store.SearchIssues(ctx, "", types.IssueFilter{}) + + validIDs := make(map[string]bool) + for _, issue := range issues { + validIDs[issue.ID] = true + } + + orphanCount := 0 + depTypes := make(map[types.DependencyType]int) + for issueID, deps := range allDeps { + if !validIDs[issueID] { + continue + } + for _, dep := range deps { + if !validIDs[dep.DependsOnID] { + orphanCount++ + depTypes[dep.Type]++ + } + } + } + + if orphanCount != 2 { + t.Errorf("Expected 2 orphans, got %d", orphanCount) + } + if depTypes[types.DepBlocks] != 1 { + t.Errorf("Expected 1 blocks orphan, got %d", depTypes[types.DepBlocks]) + } + if depTypes[types.DepRelated] != 1 { + t.Errorf("Expected 1 related orphan, got %d", depTypes[types.DepRelated]) + } +}