diff --git a/AGENTS.md b/AGENTS.md index 78dc5260..04dea87c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,6 +198,11 @@ bd duplicates --dry-run # Preview merge operation # Merge specific duplicate issues bd merge --into --json # Consolidate duplicates bd merge bd-42 bd-43 --into bd-41 --dry-run # Preview merge + +# Migrate databases after version upgrade +bd migrate # Detect and migrate old databases +bd migrate --dry-run # Preview migration +bd migrate --cleanup --yes # Migrate and remove old files ``` ### Managing Daemons diff --git a/QUICKSTART.md b/QUICKSTART.md index 1017497b..b89c30c6 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -94,6 +94,21 @@ You can use project-specific databases: ./bd --db ./my-project.db create "Task" ``` +## Migrating Databases + +After upgrading bd, use `bd migrate` to check for and migrate old database files: + +```bash +# Check for migration opportunities +./bd migrate --dry-run + +# Migrate old databases to beads.db +./bd migrate + +# Migrate and clean up old files +./bd migrate --cleanup --yes +``` + ## Next Steps - Add labels: `./bd create "Task" -l "backend,urgent"` diff --git a/cmd/bd/daemon.go b/cmd/bd/daemon.go index 357a5ff3..59f53618 100644 --- a/cmd/bd/daemon.go +++ b/cmd/bd/daemon.go @@ -1121,8 +1121,8 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, p log.log("This may cause compatibility issues.") log.log("") log.log("Options:") - log.log(" 1. Upgrade/downgrade bd to match database version: %s", dbVersion) - log.log(" 2. Run 'bd init' to update the database to the current version") + log.log(" 1. Run 'bd migrate' to update the database to the current version") + log.log(" 2. Upgrade/downgrade bd to match database version: %s", dbVersion) log.log(" 3. Set BEADS_IGNORE_VERSION_MISMATCH=1 to proceed anyway (not recommended)") log.log("") diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 248aeaa1..7ae43879 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -1032,7 +1032,8 @@ func checkVersionMismatch() { } else if cmp > 0 { // Binary is newer than database fmt.Fprintf(os.Stderr, "%s\n", yellow("⚠️ Your binary appears NEWER than the database.")) - fmt.Fprintf(os.Stderr, "%s\n\n", yellow("⚠️ The database will be upgraded automatically.")) + fmt.Fprintf(os.Stderr, "%s\n", yellow("⚠️ Run 'bd migrate' to check for and migrate old database files.")) + fmt.Fprintf(os.Stderr, "%s\n\n", yellow("⚠️ The current database version will be updated automatically.")) // Update stored version to current _ = store.SetMetadata(ctx, "bd_version", Version) } diff --git a/cmd/bd/migrate.go b/cmd/bd/migrate.go new file mode 100644 index 00000000..d99b72cc --- /dev/null +++ b/cmd/bd/migrate.go @@ -0,0 +1,371 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/storage/sqlite" + _ "modernc.org/sqlite" +) + +var migrateCmd = &cobra.Command{ + Use: "migrate", + Short: "Migrate database to current version", + Long: `Detect and migrate database files to the current version. + +This command: +- Finds all .db files in .beads/ +- Checks schema versions +- Migrates old databases to beads.db +- Updates schema version metadata +- Removes stale databases (with confirmation)`, + Run: func(cmd *cobra.Command, _ []string) { + autoYes, _ := cmd.Flags().GetBool("yes") + cleanup, _ := cmd.Flags().GetBool("cleanup") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + // Find .beads directory + beadsDir := findBeadsDir() + if beadsDir == "" { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "no_beads_directory", + "message": "No .beads directory found. Run 'bd init' first.", + }) + } else { + fmt.Fprintf(os.Stderr, "Error: no .beads directory found\n") + fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to initialize bd\n") + } + os.Exit(1) + } + + // Detect all database files + databases, err := detectDatabases(beadsDir) + if err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "detection_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } + os.Exit(1) + } + + if len(databases) == 0 { + if jsonOutput { + outputJSON(map[string]interface{}{ + "status": "no_databases", + "message": "No database files found in .beads/", + }) + } else { + fmt.Fprintf(os.Stderr, "No database files found in %s\n", beadsDir) + fmt.Fprintf(os.Stderr, "Run 'bd init' to create a new database.\n") + } + return + } + + // Check if beads.db exists and is current + targetPath := filepath.Join(beadsDir, "beads.db") + var currentDB *dbInfo + var oldDBs []*dbInfo + + for _, db := range databases { + if db.path == targetPath { + currentDB = db + } else { + oldDBs = append(oldDBs, db) + } + } + + // Print status + if !jsonOutput { + fmt.Printf("Database migration status:\n\n") + if currentDB != nil { + fmt.Printf(" Current database: %s\n", filepath.Base(currentDB.path)) + fmt.Printf(" Schema version: %s\n", currentDB.version) + if currentDB.version != Version { + color.Yellow(" ⚠ Version mismatch (current: %s, expected: %s)\n", currentDB.version, Version) + } else { + color.Green(" ✓ Version matches\n") + } + } else { + color.Yellow(" No beads.db found\n") + } + + if len(oldDBs) > 0 { + fmt.Printf("\n Old databases found:\n") + for _, db := range oldDBs { + fmt.Printf(" - %s (version: %s)\n", filepath.Base(db.path), db.version) + } + } + fmt.Println() + } + + // Determine migration actions + needsMigration := false + needsVersionUpdate := false + + if currentDB == nil && len(oldDBs) == 1 { + // Migrate single old database to beads.db + needsMigration = true + } else if currentDB == nil && len(oldDBs) > 1 { + // Multiple old databases - ambiguous + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "ambiguous_migration", + "message": "Multiple old database files found", + "databases": formatDBList(oldDBs), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: multiple old database files found:\n") + for _, db := range oldDBs { + fmt.Fprintf(os.Stderr, " - %s (version: %s)\n", filepath.Base(db.path), db.version) + } + fmt.Fprintf(os.Stderr, "\nPlease manually rename the correct database to beads.db and remove others.\n") + } + os.Exit(1) + } else if currentDB != nil && currentDB.version != Version { + // Update version metadata + needsVersionUpdate = true + } + + // Perform migrations + if dryRun { + if jsonOutput { + outputJSON(map[string]interface{}{ + "dry_run": true, + "needs_migration": needsMigration, + "needs_version_update": needsVersionUpdate, + "old_databases": formatDBList(oldDBs), + }) + } else { + fmt.Println("Dry run mode - no changes will be made") + if needsMigration { + fmt.Printf("Would migrate: %s → beads.db\n", filepath.Base(oldDBs[0].path)) + } + if needsVersionUpdate { + fmt.Printf("Would update version: %s → %s\n", currentDB.version, Version) + } + if cleanup && len(oldDBs) > 0 { + fmt.Printf("Would remove %d old database(s)\n", len(oldDBs)) + } + } + return + } + + // Migrate old database to beads.db + if needsMigration { + oldDB := oldDBs[0] + if !jsonOutput { + fmt.Printf("Migrating database: %s → beads.db\n", filepath.Base(oldDB.path)) + } + + if err := os.Rename(oldDB.path, targetPath); err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "migration_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to migrate database: %v\n", err) + } + os.Exit(1) + } + + // Update current DB reference + currentDB = oldDB + currentDB.path = targetPath + needsVersionUpdate = true + + if !jsonOutput { + color.Green("✓ Migration complete\n\n") + } + } + + // Update schema version if needed + if needsVersionUpdate && currentDB != nil { + if !jsonOutput { + fmt.Printf("Updating schema version: %s → %s\n", currentDB.version, Version) + } + + store, err := sqlite.New(currentDB.path) + if err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "version_update_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err) + } + os.Exit(1) + } + + ctx := context.Background() + if err := store.SetMetadata(ctx, "bd_version", Version); err != nil { + _ = store.Close() + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "version_update_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to update version: %v\n", err) + } + os.Exit(1) + } + _ = store.Close() + + if !jsonOutput { + color.Green("✓ Version updated\n\n") + } + } + + // Clean up old databases + if cleanup && len(oldDBs) > 0 { + // If we migrated one database, remove it from the cleanup list + if needsMigration { + oldDBs = oldDBs[1:] + } + + if len(oldDBs) > 0 { + if !autoYes && !jsonOutput { + fmt.Printf("Found %d old database file(s):\n", len(oldDBs)) + for _, db := range oldDBs { + fmt.Printf(" - %s (version: %s)\n", filepath.Base(db.path), db.version) + } + fmt.Print("\nRemove these files? [y/N] ") + var response string + fmt.Scanln(&response) + if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" { + fmt.Println("Cleanup cancelled") + return + } + } + + for _, db := range oldDBs { + if err := os.Remove(db.path); err != nil { + if !jsonOutput { + color.Yellow("Warning: failed to remove %s: %v\n", filepath.Base(db.path), err) + } + } else if !jsonOutput { + fmt.Printf("Removed %s\n", filepath.Base(db.path)) + } + } + + if !jsonOutput { + color.Green("\n✓ Cleanup complete\n") + } + } + } + + // Final status + if jsonOutput { + outputJSON(map[string]interface{}{ + "status": "success", + "current_database": "beads.db", + "version": Version, + "migrated": needsMigration, + "version_updated": needsVersionUpdate, + "cleaned_up": cleanup && len(oldDBs) > 0, + }) + } else { + fmt.Println("\nMigration complete!") + fmt.Printf("Current database: beads.db (version %s)\n", Version) + } + }, +} + +type dbInfo struct { + path string + version string +} + +func detectDatabases(beadsDir string) ([]*dbInfo, error) { + pattern := filepath.Join(beadsDir, "*.db") + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("failed to search for databases: %w", err) + } + + var databases []*dbInfo + for _, match := range matches { + // Skip backup files + if strings.HasSuffix(match, ".backup.db") { + continue + } + + // Check if file exists and is readable + info, err := os.Stat(match) + if err != nil || info.IsDir() { + continue + } + + // Get version from database + version := getDBVersion(match) + databases = append(databases, &dbInfo{ + path: match, + version: version, + }) + } + + return databases, nil +} + +func getDBVersion(dbPath string) string { + // Open database read-only + db, err := sql.Open("sqlite", dbPath+"?mode=ro") + if err != nil { + return "unknown" + } + defer db.Close() + + // Try to read version from metadata table + var version string + err = db.QueryRow("SELECT value FROM metadata WHERE key = 'bd_version'").Scan(&version) + if err == nil { + return version + } + + // Check if metadata table exists + var tableName string + err = db.QueryRow(` + SELECT name FROM sqlite_master + WHERE type='table' AND name='metadata' + `).Scan(&tableName) + + if err == sql.ErrNoRows { + return "pre-0.17.5" + } + + return "unknown" +} + + + +func formatDBList(dbs []*dbInfo) []map[string]string { + result := make([]map[string]string, len(dbs)) + for i, db := range dbs { + result[i] = map[string]string{ + "path": db.path, + "name": filepath.Base(db.path), + "version": db.version, + } + } + return result +} + +func init() { + migrateCmd.Flags().Bool("yes", false, "Auto-confirm cleanup prompts") + migrateCmd.Flags().Bool("cleanup", false, "Remove old database files after migration") + migrateCmd.Flags().Bool("dry-run", false, "Show what would be done without making changes") + rootCmd.AddCommand(migrateCmd) +} diff --git a/cmd/bd/migrate_test.go b/cmd/bd/migrate_test.go new file mode 100644 index 00000000..d332858d --- /dev/null +++ b/cmd/bd/migrate_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/storage/sqlite" +) + +func TestMigrateCommand(t *testing.T) { + // Create temporary test directory + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0750); err != nil { + t.Fatalf("Failed to create .beads directory: %v", err) + } + + t.Run("no databases", func(t *testing.T) { + databases, err := detectDatabases(beadsDir) + if err != nil { + t.Fatalf("detectDatabases failed: %v", err) + } + if len(databases) != 0 { + t.Errorf("Expected 0 databases, got %d", len(databases)) + } + }) + + t.Run("single old database", func(t *testing.T) { + // Create old database + oldDBPath := filepath.Join(beadsDir, "vc.db") + store, err := sqlite.New(oldDBPath) + if err != nil { + t.Fatalf("Failed to create old database: %v", err) + } + + // Set old version + ctx := context.Background() + if err := store.SetMetadata(ctx, "bd_version", "0.16.0"); err != nil { + t.Fatalf("Failed to set old version: %v", err) + } + _ = store.Close() + + // Detect databases + databases, err := detectDatabases(beadsDir) + if err != nil { + t.Fatalf("detectDatabases failed: %v", err) + } + if len(databases) != 1 { + t.Fatalf("Expected 1 database, got %d", len(databases)) + } + if databases[0].version != "0.16.0" { + t.Errorf("Expected version 0.16.0, got %s", databases[0].version) + } + + // Migrate to beads.db + targetPath := filepath.Join(beadsDir, "beads.db") + if err := os.Rename(oldDBPath, targetPath); err != nil { + t.Fatalf("Failed to migrate database: %v", err) + } + + // Verify migration + databases, err = detectDatabases(beadsDir) + if err != nil { + t.Fatalf("detectDatabases failed after migration: %v", err) + } + if len(databases) != 1 { + t.Fatalf("Expected 1 database after migration, got %d", len(databases)) + } + if filepath.Base(databases[0].path) != "beads.db" { + t.Errorf("Expected beads.db, got %s", filepath.Base(databases[0].path)) + } + }) + + t.Run("version detection", func(t *testing.T) { + // Test getDBVersion with beads.db from previous test + dbPath := filepath.Join(beadsDir, "beads.db") + version := getDBVersion(dbPath) + if version != "0.16.0" { + t.Errorf("Expected version 0.16.0, got %s", version) + } + + // Update version + store, err := sqlite.New(dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + ctx := context.Background() + if err := store.SetMetadata(ctx, "bd_version", Version); err != nil { + t.Fatalf("Failed to update version: %v", err) + } + _ = store.Close() + + // Verify updated version + version = getDBVersion(dbPath) + if version != Version { + t.Errorf("Expected version %s, got %s", Version, version) + } + }) +} + +func TestFormatDBList(t *testing.T) { + dbs := []*dbInfo{ + {path: "/tmp/.beads/beads.db", version: "0.17.5"}, + {path: "/tmp/.beads/old.db", version: "0.16.0"}, + } + + result := formatDBList(dbs) + if len(result) != 2 { + t.Fatalf("Expected 2 results, got %d", len(result)) + } + + if result[0]["name"] != "beads.db" { + t.Errorf("Expected name beads.db, got %s", result[0]["name"]) + } + if result[0]["version"] != "0.17.5" { + t.Errorf("Expected version 0.17.5, got %s", result[0]["version"]) + } + + if result[1]["name"] != "old.db" { + t.Errorf("Expected name old.db, got %s", result[1]["name"]) + } + if result[1]["version"] != "0.16.0" { + t.Errorf("Expected version 0.16.0, got %s", result[1]["version"]) + } +}