diff --git a/cmd/bd/migrate.go b/cmd/bd/migrate.go index 927d2c25..9b5e9984 100644 --- a/cmd/bd/migrate.go +++ b/cmd/bd/migrate.go @@ -33,6 +33,10 @@ Without subcommand, detects and migrates database schema to current version: - Updates schema version metadata - Removes stale databases (with confirmation) +Backend migration flags: + --to-dolt Migrate from SQLite to Dolt backend + --to-sqlite Migrate from Dolt to SQLite backend (escape hatch) + Subcommands: hash-ids Migrate sequential IDs to hash-based IDs (legacy) issues Move issues between repositories @@ -62,6 +66,20 @@ Subcommands: return } + // Handle --to-dolt flag (SQLite to Dolt migration) + toDolt, _ := cmd.Flags().GetBool("to-dolt") + if toDolt { + handleToDoltMigration(dryRun, autoYes) + return + } + + // Handle --to-sqlite flag (Dolt to SQLite migration, escape hatch) + toSQLite, _ := cmd.Flags().GetBool("to-sqlite") + if toSQLite { + handleToSQLiteMigration(dryRun, autoYes) + return + } + // Find .beads directory beadsDir := beads.FindBeadsDir() if beadsDir == "" { @@ -974,6 +992,8 @@ func init() { migrateCmd.Flags().Bool("dry-run", false, "Show what would be done without making changes") migrateCmd.Flags().Bool("update-repo-id", false, "Update repository ID (use after changing git remote)") migrateCmd.Flags().Bool("inspect", false, "Show migration plan and database state for AI agent analysis") + migrateCmd.Flags().Bool("to-dolt", false, "Migrate from SQLite to Dolt backend") + migrateCmd.Flags().Bool("to-sqlite", false, "Migrate from Dolt to SQLite backend (escape hatch)") migrateCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output migration statistics in JSON format") rootCmd.AddCommand(migrateCmd) } diff --git a/cmd/bd/migrate_dolt.go b/cmd/bd/migrate_dolt.go new file mode 100644 index 00000000..e480162d --- /dev/null +++ b/cmd/bd/migrate_dolt.go @@ -0,0 +1,897 @@ +//go:build cgo + +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/storage/dolt" + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" +) + +// handleToDolt migrates from SQLite to Dolt backend. +// This implements Part 7 of DOLT-STORAGE-DESIGN.md: +// 1. Creates Dolt database in `.beads/dolt/` +// 2. Imports all issues from SQLite +// 3. Updates `metadata.json` to use Dolt +// 4. Keeps JSONL export enabled by default +// 5. SQLite file can be deleted after verification +func handleToDoltMigration(dryRun bool, autoYes bool) { + // Find .beads directory + beadsDir := beads.FindBeadsDir() + if beadsDir == "" { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "no_beads_directory", + "message": "No .beads directory found. Run 'bd init' first.", + }) + } else { + FatalErrorWithHint("no .beads directory found", "run 'bd init' to initialize bd") + } + os.Exit(1) + } + + // Load config + cfg, err := configfile.Load(beadsDir) + if err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "config_load_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to load config: %v\n", err) + } + os.Exit(1) + } + if cfg == nil { + cfg = configfile.DefaultConfig() + } + + // Check if already using Dolt + if cfg.GetBackend() == configfile.BackendDolt { + if jsonOutput { + outputJSON(map[string]interface{}{ + "status": "noop", + "message": "Already using Dolt backend", + }) + } else { + fmt.Printf("%s\n", ui.RenderPass("✓ Already using Dolt backend")) + fmt.Println("No migration needed") + } + return + } + + // Find SQLite database + sqlitePath := cfg.DatabasePath(beadsDir) + if _, err := os.Stat(sqlitePath); os.IsNotExist(err) { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "no_sqlite_database", + "message": "No SQLite database found to migrate", + }) + } else { + fmt.Fprintf(os.Stderr, "Error: no SQLite database found at %s\n", sqlitePath) + fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to initialize bd first\n") + } + os.Exit(1) + } + + // Dolt path + doltPath := filepath.Join(beadsDir, "dolt") + + // Check if Dolt directory already exists + if _, err := os.Stat(doltPath); err == nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "dolt_exists", + "message": fmt.Sprintf("Dolt directory already exists at %s", doltPath), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: Dolt directory already exists at %s\n", doltPath) + fmt.Fprintf(os.Stderr, "Hint: remove it first if you want to re-migrate, or switch backend with 'bd config backend dolt'\n") + } + os.Exit(1) + } + + // Open SQLite to count issues + ctx := context.Background() + sqliteStore, err := sqlite.NewReadOnly(ctx, sqlitePath) + if err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "sqlite_open_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to open SQLite database: %v\n", err) + } + os.Exit(1) + } + + // Get all issues from SQLite + issues, err := sqliteStore.SearchIssues(ctx, "", types.IssueFilter{IncludeTombstones: true}) + if err != nil { + _ = sqliteStore.Close() + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "fetch_issues_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to fetch issues: %v\n", err) + } + os.Exit(1) + } + + // Get labels for each issue + issueIDs := make([]string, len(issues)) + for i, issue := range issues { + issueIDs[i] = issue.ID + } + labelsMap, err := sqliteStore.GetLabelsForIssues(ctx, issueIDs) + if err != nil { + _ = sqliteStore.Close() + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "fetch_labels_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to fetch labels: %v\n", err) + } + os.Exit(1) + } + + // Get dependencies for each issue + allDeps, err := sqliteStore.GetAllDependencyRecords(ctx) + if err != nil { + _ = sqliteStore.Close() + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "fetch_deps_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to fetch dependencies: %v\n", err) + } + os.Exit(1) + } + + // Get config values + prefix, _ := sqliteStore.GetConfig(ctx, "issue_prefix") + + // Close SQLite - we have all the data we need + _ = sqliteStore.Close() + + // Assign labels and dependencies to issues + for _, issue := range issues { + if labels, ok := labelsMap[issue.ID]; ok { + issue.Labels = labels + } + if deps, ok := allDeps[issue.ID]; ok { + issue.Dependencies = deps + } + } + + if !jsonOutput { + fmt.Printf("SQLite to Dolt Migration\n") + fmt.Printf("========================\n\n") + fmt.Printf("Source: %s\n", sqlitePath) + fmt.Printf("Target: %s\n", doltPath) + fmt.Printf("Issues to migrate: %d\n", len(issues)) + if prefix != "" { + fmt.Printf("Issue prefix: %s\n", prefix) + } + fmt.Println() + } + + // Dry run mode + if dryRun { + if jsonOutput { + outputJSON(map[string]interface{}{ + "dry_run": true, + "source": sqlitePath, + "target": doltPath, + "issue_count": len(issues), + "prefix": prefix, + "would_backup": true, + }) + } else { + fmt.Println("Dry run mode - no changes will be made") + fmt.Println("Would perform:") + fmt.Printf(" 1. Create backup of SQLite database\n") + fmt.Printf(" 2. Create Dolt database at %s\n", doltPath) + fmt.Printf(" 3. Import %d issues with labels and dependencies\n", len(issues)) + fmt.Printf(" 4. Update metadata.json to use Dolt backend\n") + } + return + } + + // Prompt for confirmation + if !autoYes && !jsonOutput { + fmt.Printf("This will:\n") + fmt.Printf(" 1. Create a backup of your SQLite database\n") + fmt.Printf(" 2. Create a Dolt database and import all issues\n") + fmt.Printf(" 3. Update metadata.json to use Dolt backend\n") + fmt.Printf(" 4. Keep your SQLite database (can be deleted after verification)\n\n") + fmt.Printf("Continue? [y/N] ") + var response string + _, _ = fmt.Scanln(&response) + if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" { + fmt.Println("Migration canceled") + return + } + fmt.Println() + } + + // Create backup + backupPath := strings.TrimSuffix(sqlitePath, ".db") + ".backup-pre-dolt-" + time.Now().Format("20060102-150405") + ".db" + if err := copyFile(sqlitePath, backupPath); err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "backup_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to create backup: %v\n", err) + } + os.Exit(1) + } + if !jsonOutput { + fmt.Printf("%s\n", ui.RenderPass(fmt.Sprintf("✓ Created backup: %s", filepath.Base(backupPath)))) + } + + // Create Dolt database + if !jsonOutput { + fmt.Printf("Creating Dolt database...\n") + } + + doltStore, err := dolt.New(ctx, &dolt.Config{Path: doltPath}) + if err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "dolt_create_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to create Dolt database: %v\n", err) + } + os.Exit(1) + } + + // Set issue prefix + if prefix != "" { + if err := doltStore.SetConfig(ctx, "issue_prefix", prefix); err != nil { + _ = doltStore.Close() + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "set_prefix_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to set issue prefix: %v\n", err) + } + os.Exit(1) + } + } + + // Import issues + if !jsonOutput { + fmt.Printf("Importing %d issues...\n", len(issues)) + } + + imported, skipped, err := importIssuesToDolt(ctx, doltStore, issues) + if err != nil { + _ = doltStore.Close() + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "import_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to import issues: %v\n", err) + } + os.Exit(1) + } + + // Commit the migration + commitMsg := fmt.Sprintf("Migrate from SQLite: %d issues imported", imported) + if err := doltStore.Commit(ctx, commitMsg); err != nil { + // Non-fatal - data is still in the database + if !jsonOutput { + fmt.Printf("%s\n", ui.RenderWarn(fmt.Sprintf("Warning: failed to create Dolt commit: %v", err))) + } + } + + _ = doltStore.Close() + + if !jsonOutput { + fmt.Printf("%s\n", ui.RenderPass(fmt.Sprintf("✓ Imported %d issues (%d skipped)", imported, skipped))) + } + + // Update metadata.json + cfg.Backend = configfile.BackendDolt + cfg.Database = "dolt" // Use dolt directory + if err := cfg.Save(beadsDir); err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "config_save_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to update metadata.json: %v\n", err) + } + os.Exit(1) + } + + if !jsonOutput { + fmt.Printf("%s\n", ui.RenderPass("✓ Updated metadata.json to use Dolt backend")) + } + + // Final status + if jsonOutput { + outputJSON(map[string]interface{}{ + "status": "success", + "backend": "dolt", + "issues_imported": imported, + "issues_skipped": skipped, + "backup_path": backupPath, + "dolt_path": doltPath, + }) + } else { + fmt.Println() + fmt.Printf("%s\n", ui.RenderPass("✓ Migration complete!")) + fmt.Println() + fmt.Printf("Your beads now use Dolt storage.\n") + fmt.Printf("SQLite backup: %s\n", backupPath) + fmt.Println() + fmt.Println("Next steps:") + fmt.Println(" • Verify data: bd list") + fmt.Println(" • After verification, you can delete the old SQLite database:") + fmt.Printf(" rm %s\n", sqlitePath) + fmt.Println() + fmt.Println("To switch back to SQLite: bd migrate --to-sqlite") + } +} + +// importIssuesToDolt imports issues to Dolt, returning (imported, skipped, error) +func importIssuesToDolt(ctx context.Context, store *dolt.DoltStore, issues []*types.Issue) (int, int, error) { + tx, err := store.UnderlyingDB().BeginTx(ctx, nil) + if err != nil { + return 0, 0, fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + imported := 0 + skipped := 0 + seenIDs := make(map[string]bool) + + for _, issue := range issues { + // Skip duplicates + if seenIDs[issue.ID] { + skipped++ + continue + } + seenIDs[issue.ID] = true + + // Compute content hash if missing + if issue.ContentHash == "" { + issue.ContentHash = issue.ComputeContentHash() + } + + // Insert issue directly via SQL (bypass validation since we're migrating existing data) + _, err := tx.ExecContext(ctx, ` + INSERT INTO issues ( + id, content_hash, title, description, design, acceptance_criteria, notes, + status, priority, issue_type, assignee, estimated_minutes, + created_at, created_by, owner, updated_at, closed_at, external_ref, + compaction_level, compacted_at, compacted_at_commit, original_size, + deleted_at, deleted_by, delete_reason, original_type, + sender, ephemeral, pinned, is_template, crystallizes, + mol_type, work_type, quality_score, source_system, source_repo, close_reason, + event_kind, actor, target, payload, + await_type, await_id, timeout_ns, waiters, + hook_bead, role_bead, agent_state, last_activity, role_type, rig, + due_at, defer_until + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ? + ) + `, + issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, + issue.Status, issue.Priority, issue.IssueType, nullableString(issue.Assignee), nullableIntPtr(issue.EstimatedMinutes), + issue.CreatedAt, issue.CreatedBy, issue.Owner, issue.UpdatedAt, issue.ClosedAt, nullableStringPtr(issue.ExternalRef), + issue.CompactionLevel, issue.CompactedAt, nullableStringPtr(issue.CompactedAtCommit), nullableInt(issue.OriginalSize), + issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, + issue.Sender, issue.Ephemeral, issue.Pinned, issue.IsTemplate, issue.Crystallizes, + issue.MolType, issue.WorkType, nullableFloat32Ptr(issue.QualityScore), issue.SourceSystem, issue.SourceRepo, issue.CloseReason, + issue.EventKind, issue.Actor, issue.Target, issue.Payload, + issue.AwaitType, issue.AwaitID, issue.Timeout.Nanoseconds(), formatWaiters(issue.Waiters), + issue.HookBead, issue.RoleBead, issue.AgentState, issue.LastActivity, issue.RoleType, issue.Rig, + issue.DueAt, issue.DeferUntil, + ) + if err != nil { + if strings.Contains(err.Error(), "Duplicate entry") || + strings.Contains(err.Error(), "UNIQUE constraint") { + skipped++ + continue + } + return imported, skipped, fmt.Errorf("failed to insert issue %s: %w", issue.ID, err) + } + + // Insert labels + for _, label := range issue.Labels { + _, err := tx.ExecContext(ctx, ` + INSERT INTO labels (issue_id, label) + VALUES (?, ?) + `, issue.ID, label) + if err != nil && !strings.Contains(err.Error(), "Duplicate entry") { + return imported, skipped, fmt.Errorf("failed to insert label for %s: %w", issue.ID, err) + } + } + + imported++ + } + + // Import dependencies in a second pass (after all issues exist) + for _, issue := range issues { + for _, dep := range issue.Dependencies { + // Check if both issues exist + var exists int + err := tx.QueryRowContext(ctx, "SELECT 1 FROM issues WHERE id = ?", dep.DependsOnID).Scan(&exists) + if err != nil { + // Target doesn't exist, skip dependency + continue + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE type = type + `, dep.IssueID, dep.DependsOnID, dep.Type, dep.CreatedBy, dep.CreatedAt) + if err != nil && !strings.Contains(err.Error(), "Duplicate entry") { + // Non-fatal for dependencies - log and continue + continue + } + } + } + + if err := tx.Commit(); err != nil { + return imported, skipped, fmt.Errorf("failed to commit: %w", err) + } + + return imported, skipped, nil +} + +// handleToSQLite migrates from Dolt to SQLite backend (escape hatch). +func handleToSQLiteMigration(dryRun bool, autoYes bool) { + // Find .beads directory + beadsDir := beads.FindBeadsDir() + if beadsDir == "" { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "no_beads_directory", + "message": "No .beads directory found. Run 'bd init' first.", + }) + } else { + FatalErrorWithHint("no .beads directory found", "run 'bd init' to initialize bd") + } + os.Exit(1) + } + + // Load config + cfg, err := configfile.Load(beadsDir) + if err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "config_load_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to load config: %v\n", err) + } + os.Exit(1) + } + if cfg == nil { + cfg = configfile.DefaultConfig() + } + + // Check if already using SQLite + if cfg.GetBackend() == configfile.BackendSQLite { + if jsonOutput { + outputJSON(map[string]interface{}{ + "status": "noop", + "message": "Already using SQLite backend", + }) + } else { + fmt.Printf("%s\n", ui.RenderPass("✓ Already using SQLite backend")) + fmt.Println("No migration needed") + } + return + } + + // Find Dolt database + doltPath := cfg.DatabasePath(beadsDir) + if _, err := os.Stat(doltPath); os.IsNotExist(err) { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "no_dolt_database", + "message": "No Dolt database found to migrate", + }) + } else { + fmt.Fprintf(os.Stderr, "Error: no Dolt database found at %s\n", doltPath) + } + os.Exit(1) + } + + // SQLite path + sqlitePath := filepath.Join(beadsDir, "beads.db") + + // Check if SQLite database already exists + if _, err := os.Stat(sqlitePath); err == nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "sqlite_exists", + "message": fmt.Sprintf("SQLite database already exists at %s", sqlitePath), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: SQLite database already exists at %s\n", sqlitePath) + fmt.Fprintf(os.Stderr, "Hint: remove it first if you want to re-migrate\n") + } + os.Exit(1) + } + + ctx := context.Background() + + // Open Dolt to count issues + doltStore, err := dolt.New(ctx, &dolt.Config{Path: doltPath, ReadOnly: true}) + if err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "dolt_open_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to open Dolt database: %v\n", err) + } + os.Exit(1) + } + + // Get all issues from Dolt + issues, err := doltStore.SearchIssues(ctx, "", types.IssueFilter{IncludeTombstones: true}) + if err != nil { + _ = doltStore.Close() + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "fetch_issues_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to fetch issues: %v\n", err) + } + os.Exit(1) + } + + // Get labels and dependencies for each issue + issueIDs := make([]string, len(issues)) + for i, issue := range issues { + issueIDs[i] = issue.ID + } + labelsMap, err := doltStore.GetLabelsForIssues(ctx, issueIDs) + if err != nil { + _ = doltStore.Close() + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "fetch_labels_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to fetch labels: %v\n", err) + } + os.Exit(1) + } + + allDeps, err := doltStore.GetAllDependencyRecords(ctx) + if err != nil { + _ = doltStore.Close() + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "fetch_deps_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to fetch dependencies: %v\n", err) + } + os.Exit(1) + } + + // Get config values + prefix, _ := doltStore.GetConfig(ctx, "issue_prefix") + + _ = doltStore.Close() + + // Assign labels and dependencies to issues + for _, issue := range issues { + if labels, ok := labelsMap[issue.ID]; ok { + issue.Labels = labels + } + if deps, ok := allDeps[issue.ID]; ok { + issue.Dependencies = deps + } + } + + if !jsonOutput { + fmt.Printf("Dolt to SQLite Migration\n") + fmt.Printf("========================\n\n") + fmt.Printf("Source: %s\n", doltPath) + fmt.Printf("Target: %s\n", sqlitePath) + fmt.Printf("Issues to migrate: %d\n", len(issues)) + if prefix != "" { + fmt.Printf("Issue prefix: %s\n", prefix) + } + fmt.Println() + } + + // Dry run mode + if dryRun { + if jsonOutput { + outputJSON(map[string]interface{}{ + "dry_run": true, + "source": doltPath, + "target": sqlitePath, + "issue_count": len(issues), + "prefix": prefix, + }) + } else { + fmt.Println("Dry run mode - no changes will be made") + fmt.Println("Would perform:") + fmt.Printf(" 1. Create SQLite database at %s\n", sqlitePath) + fmt.Printf(" 2. Import %d issues with labels and dependencies\n", len(issues)) + fmt.Printf(" 3. Update metadata.json to use SQLite backend\n") + } + return + } + + // Prompt for confirmation + if !autoYes && !jsonOutput { + fmt.Printf("This will:\n") + fmt.Printf(" 1. Create a SQLite database and import all issues\n") + fmt.Printf(" 2. Update metadata.json to use SQLite backend\n") + fmt.Printf(" 3. Keep your Dolt database (can be deleted after verification)\n\n") + fmt.Printf("Continue? [y/N] ") + var response string + _, _ = fmt.Scanln(&response) + if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" { + fmt.Println("Migration canceled") + return + } + fmt.Println() + } + + // Create SQLite database + if !jsonOutput { + fmt.Printf("Creating SQLite database...\n") + } + + sqliteStore, err := sqlite.New(ctx, sqlitePath) + if err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "sqlite_create_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to create SQLite database: %v\n", err) + } + os.Exit(1) + } + + // Set issue prefix + if prefix != "" { + if err := sqliteStore.SetConfig(ctx, "issue_prefix", prefix); err != nil { + _ = sqliteStore.Close() + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "set_prefix_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to set issue prefix: %v\n", err) + } + os.Exit(1) + } + } + + // Import issues + if !jsonOutput { + fmt.Printf("Importing %d issues...\n", len(issues)) + } + + imported, skipped, err := importIssuesToSQLite(ctx, sqliteStore, issues) + if err != nil { + _ = sqliteStore.Close() + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "import_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to import issues: %v\n", err) + } + os.Exit(1) + } + + _ = sqliteStore.Close() + + if !jsonOutput { + fmt.Printf("%s\n", ui.RenderPass(fmt.Sprintf("✓ Imported %d issues (%d skipped)", imported, skipped))) + } + + // Update metadata.json + cfg.Backend = configfile.BackendSQLite + cfg.Database = "beads.db" + if err := cfg.Save(beadsDir); err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "config_save_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to update metadata.json: %v\n", err) + } + os.Exit(1) + } + + if !jsonOutput { + fmt.Printf("%s\n", ui.RenderPass("✓ Updated metadata.json to use SQLite backend")) + } + + // Final status + if jsonOutput { + outputJSON(map[string]interface{}{ + "status": "success", + "backend": "sqlite", + "issues_imported": imported, + "issues_skipped": skipped, + "sqlite_path": sqlitePath, + }) + } else { + fmt.Println() + fmt.Printf("%s\n", ui.RenderPass("✓ Migration complete!")) + fmt.Println() + fmt.Printf("Your beads now use SQLite storage.\n") + fmt.Println() + fmt.Println("Next steps:") + fmt.Println(" • Verify data: bd list") + fmt.Println(" • After verification, you can delete the Dolt directory:") + fmt.Printf(" rm -rf %s\n", doltPath) + } +} + +// importIssuesToSQLite imports issues to SQLite, returning (imported, skipped, error) +func importIssuesToSQLite(ctx context.Context, store *sqlite.SQLiteStorage, issues []*types.Issue) (int, int, error) { + imported := 0 + skipped := 0 + + err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { + seenIDs := make(map[string]bool) + + for _, issue := range issues { + if seenIDs[issue.ID] { + skipped++ + continue + } + seenIDs[issue.ID] = true + + // Create issue (labels are handled separately) + savedLabels := issue.Labels + savedDeps := issue.Dependencies + issue.Labels = nil // Don't process in CreateIssue + issue.Dependencies = nil // Don't process in CreateIssue + + if err := tx.CreateIssue(ctx, issue, "migration"); err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint") || + strings.Contains(err.Error(), "already exists") { + skipped++ + issue.Labels = savedLabels + issue.Dependencies = savedDeps + continue + } + return fmt.Errorf("failed to insert issue %s: %w", issue.ID, err) + } + + // Add labels + for _, label := range savedLabels { + if err := tx.AddLabel(ctx, issue.ID, label, "migration"); err != nil { + // Non-fatal for labels + continue + } + } + + issue.Labels = savedLabels + issue.Dependencies = savedDeps + imported++ + } + + // Import dependencies in a second pass + for _, issue := range issues { + for _, dep := range issue.Dependencies { + if err := tx.AddDependency(ctx, dep, "migration"); err != nil { + // Non-fatal for dependencies + continue + } + } + } + + return nil + }) + + if err != nil { + return imported, skipped, err + } + + return imported, skipped, nil +} + +// Helper functions for nullable values +func nullableString(s string) interface{} { + if s == "" { + return nil + } + return s +} + +func nullableStringPtr(s *string) interface{} { + if s == nil { + return nil + } + return *s +} + +func nullableIntPtr(i *int) interface{} { + if i == nil { + return nil + } + return *i +} + +func nullableInt(i int) interface{} { + if i == 0 { + return nil + } + return i +} + +func nullableFloat32Ptr(f *float32) interface{} { + if f == nil { + return nil + } + return *f +} + +func formatWaiters(arr []string) string { + if len(arr) == 0 { + return "" + } + return strings.Join(arr, ",") +} diff --git a/cmd/bd/migrate_dolt_nocgo.go b/cmd/bd/migrate_dolt_nocgo.go new file mode 100644 index 00000000..cbeaea56 --- /dev/null +++ b/cmd/bd/migrate_dolt_nocgo.go @@ -0,0 +1,40 @@ +//go:build !cgo + +package main + +import ( + "fmt" + "os" +) + +// handleToDoltMigration is a stub for non-cgo builds. +// Dolt requires CGO, so this migration is not available. +func handleToDoltMigration(dryRun bool, autoYes bool) { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "dolt_not_available", + "message": "Dolt backend requires CGO. This binary was built without CGO support.", + }) + } else { + fmt.Fprintf(os.Stderr, "Error: Dolt backend requires CGO\n") + fmt.Fprintf(os.Stderr, "This binary was built without CGO support.\n") + fmt.Fprintf(os.Stderr, "To use Dolt, rebuild with: CGO_ENABLED=1 go build\n") + } + os.Exit(1) +} + +// handleToSQLiteMigration is a stub for non-cgo builds. +// Dolt requires CGO, so this migration from Dolt is not available. +func handleToSQLiteMigration(dryRun bool, autoYes bool) { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "dolt_not_available", + "message": "Dolt backend requires CGO. This binary was built without CGO support.", + }) + } else { + fmt.Fprintf(os.Stderr, "Error: Dolt backend requires CGO\n") + fmt.Fprintf(os.Stderr, "This binary was built without CGO support.\n") + fmt.Fprintf(os.Stderr, "To use Dolt, rebuild with: CGO_ENABLED=1 go build\n") + } + os.Exit(1) +}